Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]`.
Expand All @@ -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)
Expand All @@ -169,7 +173,9 @@ private fun Application.mcpStreamableHttp(
block: RoutingContext.() -> Server,
) {
installMcpContentNegotiation()
install(SSE)
if (pluginOrNull(SSE) == null) {
install(SSE)
}

val transportManager = TransportManager<StreamableHttpServerTransport>()

Expand Down Expand Up @@ -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`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,23 @@ 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
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 {
Expand Down Expand Up @@ -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.
Expand Down
Loading