From 912bec96022985e6490493671d524dcf9a549bb6 Mon Sep 17 00:00:00 2001 From: Soulkey Kim Date: Fri, 2 Oct 2026 08:35:09 +1000 Subject: [PATCH 1/2] Fix duplicate SSE plugin installation in Ktor helpers (#1079) --- .../kotlin/sdk/server/KtorServer.kt | 9 ++- .../kotlin/sdk/server/KtorExtensionsTest.kt | 62 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) 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 31274995e..c67e60b62 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 @@ -9,6 +9,7 @@ import io.ktor.server.application.ApplicationCall import io.ktor.server.application.ApplicationCallPipeline import io.ktor.server.application.MissingApplicationPluginException import io.ktor.server.application.install +import io.ktor.server.application.pluginOrNull import io.ktor.server.request.ApplicationRequest import io.ktor.server.request.header import io.ktor.server.request.httpMethod @@ -151,7 +152,9 @@ public fun Application.mcp( block: ServerSSESession.() -> Server, ) { installMcpContentNegotiation() - install(SSE) + if (pluginOrNull(SSE) == null) { + install(SSE) + } routing { mcp(enableDnsRebindingProtection, allowedHosts, allowedOrigins, maxRequestBodySize, block) @@ -169,7 +172,9 @@ private fun Application.mcpStreamableHttp( block: RoutingContext.() -> Server, ) { installMcpContentNegotiation() - install(SSE) + if (pluginOrNull(SSE) == null) { + install(SSE) + } val transportManager = TransportManager() diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorExtensionsTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorExtensionsTest.kt index 03ef3ef9a..c8028589c 100644 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorExtensionsTest.kt +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorExtensionsTest.kt @@ -4,12 +4,17 @@ import io.kotest.assertions.ktor.client.shouldHaveContentType import io.kotest.assertions.ktor.client.shouldHaveStatus import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.types.shouldBeInstanceOf import io.ktor.client.HttpClient +import io.ktor.client.request.delete import io.ktor.client.request.get +import io.ktor.client.request.header import io.ktor.client.request.post import io.ktor.client.request.prepareGet import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.http.contentType @@ -18,6 +23,12 @@ 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 io.modelcontextprotocol.kotlin.sdk.types.InitializeResult +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse +import io.modelcontextprotocol.kotlin.sdk.types.McpJson +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource import kotlin.test.Test class KtorExtensionsTest { @@ -75,6 +86,57 @@ class KtorExtensionsTest { client.assertMcpEndpointsAt("/") } + @Test + fun `Application mcp should reuse an installed SSE plugin`() = testApplication { + application { + install(SSE) + mcp(enableDnsRebindingProtection = false) { testServer() } + } + + client.assertMcpEndpointsAt("/") + } + + @ParameterizedTest + @ValueSource(booleans = [false, true]) + fun `Application mcpStreamableHttp should work with or without an installed SSE plugin`(sseInstalled: Boolean) = + testApplication { + application { + if (sseInstalled) install(SSE) + mcpStreamableHttp(enableDnsRebindingProtection = false) { testServer() } + } + + client.assertStreamableMcpEndpointAt("/mcp") + } + + @Test + fun `Application mcpStreamableHttp should register multiple endpoints`() = testApplication { + application { + mcpStreamableHttp("/first", enableDnsRebindingProtection = false) { testServer() } + mcpStreamableHttp("/second", enableDnsRebindingProtection = false) { testServer() } + } + + client.assertStreamableMcpEndpointAt("/first") + client.assertStreamableMcpEndpointAt("/second") + } + + private suspend fun HttpClient.assertStreamableMcpEndpointAt(path: String) { + val response = post(path) { + streamableHeaders() + setBody(McpJson.encodeToString(JSONRPCMessage.serializer(), initializeRequest())) + } + response.shouldHaveStatus(HttpStatusCode.OK) + response.shouldHaveContentType(ContentType.Application.Json) + val sessionId = response.headers[MCP_SESSION_ID_HEADER].shouldNotBeNull() + try { + val result = McpJson.decodeFromString(response.bodyAsText()).result + result.shouldBeInstanceOf().serverInfo.name shouldBe "test-server" + } finally { + delete(path) { + header(MCP_SESSION_ID_HEADER, sessionId) + }.shouldHaveStatus(HttpStatusCode.OK) + } + } + /** * GET at [path] opens an SSE stream announcing a session, a POST for that session is accepted, * and a POST without a session is rejected. From e7ac9338dfcd432e00ebbe3644ba75c16b07e522 Mon Sep 17 00:00:00 2001 From: devcrocod Date: Fri, 16 Oct 2026 00:42:17 +0200 Subject: [PATCH 2/2] Refactor `KtorExtensionsTest`: streamline tests, remove unused dependencies, and improve endpoint handling. Clarify reuse of the `SSE` plugin in `KtorServer` documentation. --- .../kotlin/sdk/server/KtorServer.kt | 2 + .../kotlin/sdk/server/KtorExtensionsTest.kt | 51 ++++++------------- 2 files changed, 18 insertions(+), 35 deletions(-) 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 c67e60b62..eba6f248d 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 @@ -129,6 +129,7 @@ public fun Route.mcp( * * Automatically installs [ContentNegotiation][io.ktor.server.plugins.contentnegotiation.ContentNegotiation] * with [McpJson][io.modelcontextprotocol.kotlin.sdk.types.McpJson] and [SSE]. + * An already installed [SSE] plugin is reused. * * @param enableDnsRebindingProtection whether to install [DnsRebindingProtection] on this route. Defaults to `true`. * @param allowedHosts hostnames allowed in the `Host` header. Defaults to `localhost`, `127.0.0.1`, `[::1]`. @@ -225,6 +226,7 @@ private fun Application.mcpStreamableHttp( * * Automatically installs [ContentNegotiation][io.ktor.server.plugins.contentnegotiation.ContentNegotiation] * with [McpJson][io.modelcontextprotocol.kotlin.sdk.types.McpJson] and [SSE]. + * An already installed [SSE] plugin is reused. * * @param path The base path for the MCP Streamable HTTP endpoint. Defaults to "/mcp". * @param enableDnsRebindingProtection Enables DNS rebinding attack protection for the endpoint. Defaults to `true`. diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorExtensionsTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorExtensionsTest.kt index c8028589c..69410c5ea 100644 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorExtensionsTest.kt +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorExtensionsTest.kt @@ -4,9 +4,7 @@ import io.kotest.assertions.ktor.client.shouldHaveContentType import io.kotest.assertions.ktor.client.shouldHaveStatus import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.nulls.shouldNotBeNull -import io.kotest.matchers.shouldBe import io.kotest.matchers.string.shouldContain -import io.kotest.matchers.types.shouldBeInstanceOf import io.ktor.client.HttpClient import io.ktor.client.request.delete import io.ktor.client.request.get @@ -14,8 +12,8 @@ import io.ktor.client.request.header import io.ktor.client.request.post import io.ktor.client.request.prepareGet import io.ktor.client.request.setBody -import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.contentType import io.ktor.server.application.install @@ -23,12 +21,8 @@ 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 io.modelcontextprotocol.kotlin.sdk.types.InitializeResult import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage -import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse import io.modelcontextprotocol.kotlin.sdk.types.McpJson -import org.junit.jupiter.params.ParameterizedTest -import org.junit.jupiter.params.provider.ValueSource import kotlin.test.Test class KtorExtensionsTest { @@ -96,45 +90,32 @@ class KtorExtensionsTest { client.assertMcpEndpointsAt("/") } - @ParameterizedTest - @ValueSource(booleans = [false, true]) - fun `Application mcpStreamableHttp should work with or without an installed SSE plugin`(sseInstalled: Boolean) = - testApplication { - application { - if (sseInstalled) install(SSE) - mcpStreamableHttp(enableDnsRebindingProtection = false) { testServer() } - } - - client.assertStreamableMcpEndpointAt("/mcp") - } - @Test - fun `Application mcpStreamableHttp should register multiple endpoints`() = testApplication { + fun `Application mcpStreamableHttp should register independent endpoints`() = testApplication { application { - mcpStreamableHttp("/first", enableDnsRebindingProtection = false) { testServer() } - mcpStreamableHttp("/second", enableDnsRebindingProtection = false) { testServer() } + mcpStreamableHttp("/first") { testServer() } + mcpStreamableHttp("/second") { testServer() } } - client.assertStreamableMcpEndpointAt("/first") - client.assertStreamableMcpEndpointAt("/second") + val firstSession = client.initializeStreamableSession("/first") + // Also proves /second is registered, so the 404 below comes from its own session table. + client.initializeStreamableSession("/second") + + client.delete("/second") { + header(HttpHeaders.Host, "localhost") + header(MCP_SESSION_ID_HEADER, firstSession) + }.shouldHaveStatus(HttpStatusCode.NotFound) } - private suspend fun HttpClient.assertStreamableMcpEndpointAt(path: String) { + /** POSTs `initialize` to the Streamable HTTP endpoint at [path] and returns the session id it assigns. */ + private suspend fun HttpClient.initializeStreamableSession(path: String): String { val response = post(path) { + header(HttpHeaders.Host, "localhost") streamableHeaders() setBody(McpJson.encodeToString(JSONRPCMessage.serializer(), initializeRequest())) } response.shouldHaveStatus(HttpStatusCode.OK) - response.shouldHaveContentType(ContentType.Application.Json) - val sessionId = response.headers[MCP_SESSION_ID_HEADER].shouldNotBeNull() - try { - val result = McpJson.decodeFromString(response.bodyAsText()).result - result.shouldBeInstanceOf().serverInfo.name shouldBe "test-server" - } finally { - delete(path) { - header(MCP_SESSION_ID_HEADER, sessionId) - }.shouldHaveStatus(HttpStatusCode.OK) - } + return response.headers[MCP_SESSION_ID_HEADER].shouldNotBeNull() } /**