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..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 @@ -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 @@ -128,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]`. @@ -151,7 +153,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 +173,9 @@ private fun Application.mcpStreamableHttp( block: RoutingContext.() -> Server, ) { installMcpContentNegotiation() - install(SSE) + if (pluginOrNull(SSE) == null) { + install(SSE) + } val transportManager = TransportManager() @@ -220,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 03ef3ef9a..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 @@ -6,11 +6,14 @@ import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.string.shouldContain 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.http.ContentType +import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.http.contentType import io.ktor.server.application.install @@ -18,6 +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.JSONRPCMessage +import io.modelcontextprotocol.kotlin.sdk.types.McpJson import kotlin.test.Test class KtorExtensionsTest { @@ -75,6 +80,44 @@ class KtorExtensionsTest { client.assertMcpEndpointsAt("/") } + @Test + fun `Application mcp should reuse an installed SSE plugin`() = testApplication { + application { + install(SSE) + mcp(enableDnsRebindingProtection = false) { testServer() } + } + + client.assertMcpEndpointsAt("/") + } + + @Test + fun `Application mcpStreamableHttp should register independent endpoints`() = testApplication { + application { + mcpStreamableHttp("/first") { testServer() } + mcpStreamableHttp("/second") { testServer() } + } + + 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) + } + + /** 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) + return response.headers[MCP_SESSION_ID_HEADER].shouldNotBeNull() + } + /** * 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.