fix: enable DNS rebinding protection by default (#659)
Add `DnsRebindingProtection` Ktor route-scoped plugin and enable it by default across all http transports ## Motivation and Context DNS rebinding protection was disabled by default (`enableDnsRebindingProtection = false`), and sse endpoints (`Route.mcp()`) had zero protection. Same vulnerability as typescript sdk [GHSA-w48q-cv73-mx4w](https://github.com/modelcontextprotocol/typescript-sdk/security/advisories/GHSA-w48q-cv73-mx4w) ## How Has This Been Tested? all tests pass ## Breaking Changes - `Route.mcp()`, `Application.mcp()` signatures changed (new `enableDnsRebindingProtection`, `allowedHosts`, `allowedOrigins` params), binary incompatible on JVM - `enableDnsRebindingProtection` default changed from `false` to `true` on `mcpStreamableHttp`/`mcpStatelessStreamableHttp` - Default allowed hosts: `localhost`, `127.0.0.1`, `[::1]` — opt out with `enableDnsRebindingProtection = false` - Host validation is now port-agnostic (matches ts sdk) - Origin validation now allows missing `Origin` header (non-browser clients) - `Configuration.enableDnsRebindingProtection`, `.allowedHosts`, `.allowedOrigins` deprecated in favor of the plugin ## Types of changes - [x] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [x] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update ## Checklist - [x] I have read the [MCP Documentation](https://modelcontextprotocol.io) - [x] My code follows the repository's style guidelines - [x] New and existing tests pass locally - [x] I have added appropriate error handling - [x] I have added or updated documentation as needed
This commit is contained in:
parent
c339d8cb86
commit
5cb3c1de12
9 changed files with 703 additions and 35 deletions
|
|
@ -12,8 +12,6 @@ fun main() {
|
|||
val port = System.getenv("MCP_PORT")?.toIntOrNull() ?: 3001
|
||||
embeddedServer(CIO, port = port) {
|
||||
mcpStreamableHttp(
|
||||
enableDnsRebindingProtection = true,
|
||||
allowedHosts = listOf("localhost", "127.0.0.1", "localhost:$port", "127.0.0.1:$port"),
|
||||
eventStore = InMemoryEventStore(),
|
||||
) {
|
||||
createConformanceServer()
|
||||
|
|
|
|||
|
|
@ -32,16 +32,31 @@ public final class io/modelcontextprotocol/kotlin/sdk/server/ClientConnection$De
|
|||
public static synthetic fun ping$default (Lio/modelcontextprotocol/kotlin/sdk/server/ClientConnection;Lio/modelcontextprotocol/kotlin/sdk/types/PingRequest;Lio/modelcontextprotocol/kotlin/sdk/shared/RequestOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
|
||||
}
|
||||
|
||||
public final class io/modelcontextprotocol/kotlin/sdk/server/DnsRebindingProtectionConfig {
|
||||
public fun <init> ()V
|
||||
public final fun getAllowedHosts ()Ljava/util/List;
|
||||
public final fun getAllowedOrigins ()Ljava/util/List;
|
||||
public final fun setAllowedHosts (Ljava/util/List;)V
|
||||
public final fun setAllowedOrigins (Ljava/util/List;)V
|
||||
}
|
||||
|
||||
public abstract interface class io/modelcontextprotocol/kotlin/sdk/server/EventStore {
|
||||
public abstract fun getStreamIdForEventId (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
|
||||
public abstract fun replayEventsAfter (Ljava/lang/String;Lkotlin/jvm/functions/Function3;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
|
||||
public abstract fun storeEvent (Ljava/lang/String;Lio/modelcontextprotocol/kotlin/sdk/types/JSONRPCMessage;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
|
||||
}
|
||||
|
||||
public final class io/modelcontextprotocol/kotlin/sdk/server/HostValidationKt {
|
||||
public static final fun getDnsRebindingProtection ()Lio/ktor/server/application/RouteScopedPlugin;
|
||||
}
|
||||
|
||||
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/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 mcp (Lio/ktor/server/application/Application;ZLjava/util/List;Ljava/util/List;Lkotlin/jvm/functions/Function1;)V
|
||||
public static final fun mcp (Lio/ktor/server/routing/Route;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lkotlin/jvm/functions/Function1;)V
|
||||
public static final fun mcp (Lio/ktor/server/routing/Route;ZLjava/util/List;Ljava/util/List;Lkotlin/jvm/functions/Function1;)V
|
||||
public static synthetic fun mcp$default (Lio/ktor/server/application/Application;ZLjava/util/List;Ljava/util/List;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
|
||||
public static synthetic fun mcp$default (Lio/ktor/server/routing/Route;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
|
||||
public static synthetic fun mcp$default (Lio/ktor/server/routing/Route;ZLjava/util/List;Ljava/util/List;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,185 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.server
|
||||
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.parseUrl
|
||||
import io.ktor.server.application.ApplicationCall
|
||||
import io.ktor.server.application.RouteScopedPlugin
|
||||
import io.ktor.server.application.createRouteScopedPlugin
|
||||
import io.ktor.server.request.header
|
||||
import io.ktor.server.response.respondText
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.McpJson
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.RPCError
|
||||
|
||||
/**
|
||||
* Default list of hostnames allowed for localhost DNS rebinding protection.
|
||||
* Matches the TypeScript SDK's `localhostAllowedHostnames()`.
|
||||
*/
|
||||
internal val LOCALHOST_ALLOWED_HOSTS: List<String> = listOf("localhost", "127.0.0.1", "[::1]")
|
||||
|
||||
/**
|
||||
* Characters that are valid in a URL but must not appear in an HTTP `Host` header.
|
||||
* Rejecting them prevents the parser from accepting malformed values
|
||||
* (e.g. `evil.com@localhost`, `host/path`) that a generic URL parser would silently allow.
|
||||
*/
|
||||
private val FORBIDDEN_HOST_CHARS: CharArray = charArrayOf('/', '@', '?', '#')
|
||||
|
||||
/**
|
||||
* Extracts the hostname from a Host header value, stripping the port.
|
||||
*
|
||||
* Only accepts the strict `host [ ":" port ]` / `"[" ipv6 "]" [ ":" port ]`
|
||||
* format defined by RFC 7230. Values containing URL-only characters
|
||||
* (`/`, `@`, `?`, `#`) or whitespace are rejected.
|
||||
*
|
||||
* Examples:
|
||||
* - `"localhost:3000"` → `"localhost"`
|
||||
* - `"127.0.0.1:8080"` → `"127.0.0.1"`
|
||||
* - `"[::1]:3000"` → `"[::1]"`
|
||||
* - `"example.com"` → `"example.com"`
|
||||
* - `"evil.com@localhost"` → `null`
|
||||
*
|
||||
* @return the hostname, or `null` if the value is blank, malformed, or contains forbidden characters.
|
||||
*/
|
||||
internal fun extractHostname(hostHeader: String): String? = when {
|
||||
hostHeader.isBlank() -> null
|
||||
|
||||
hostHeader.any { it in FORBIDDEN_HOST_CHARS || it.isWhitespace() } -> null
|
||||
|
||||
hostHeader.startsWith("[") -> {
|
||||
val end = hostHeader.indexOf(']')
|
||||
when {
|
||||
end <= 1 -> null
|
||||
isValidPortSuffix(hostHeader.substring(end + 1)) -> hostHeader.substring(0, end + 1)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val colon = hostHeader.indexOf(':')
|
||||
when {
|
||||
colon < 0 -> hostHeader
|
||||
colon == 0 -> null
|
||||
isValidPortSuffix(hostHeader.substring(colon)) -> hostHeader.substring(0, colon)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that [tail] is either empty (no port part) or `":"` followed by zero or more digits,
|
||||
* matching RFC 7230 §3.2.3 where `port = *DIGIT`. Non-empty non-digit characters are rejected.
|
||||
*/
|
||||
private fun isValidPortSuffix(tail: String): Boolean = when {
|
||||
tail.isEmpty() -> true
|
||||
tail[0] != ':' -> false
|
||||
else -> tail.substring(1).all { it.isDigit() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the hostname from an `Origin` header value.
|
||||
*
|
||||
* Uses Ktor's [parseUrl] to avoid reimplementing RFC 6454 parsing.
|
||||
* Scheme, port, userinfo, and path are discarded — only the host matters for
|
||||
* DNS-rebinding protection. The result is **not** normalized to lower-case;
|
||||
* callers should apply [String.lowercase] if they need case-insensitive comparison.
|
||||
*
|
||||
* @return the hostname, or `null` if [origin] cannot be parsed or has no host part.
|
||||
*/
|
||||
internal fun extractOriginHost(origin: String): String? = parseUrl(origin)?.host?.takeIf(String::isNotEmpty)
|
||||
|
||||
/**
|
||||
* Configuration for the [DnsRebindingProtection] Ktor route-scoped plugin.
|
||||
*
|
||||
* @property allowedHosts List of hostnames allowed in the `Host` header.
|
||||
* Comparison is port-agnostic and case-insensitive.
|
||||
* Defaults to `localhost`, `127.0.0.1`, `[::1]`.
|
||||
* An empty list will reject **all** requests.
|
||||
* @property allowedOrigins Optional list of allowed `Origin` values. Entries are parsed as URLs
|
||||
* (via [parseUrl]) and compared by **hostname only** — scheme and port are ignored.
|
||||
* If `null`, origin validation is disabled.
|
||||
* If configured, requests **with** an `Origin` header whose hostname is not in the list
|
||||
* are rejected, but requests **without** an `Origin` header are allowed (non-browser clients).
|
||||
*/
|
||||
public class DnsRebindingProtectionConfig {
|
||||
public var allowedHosts: List<String> = LOCALHOST_ALLOWED_HOSTS
|
||||
public var allowedOrigins: List<String>? = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Ktor route-scoped plugin that validates `Host` and `Origin` headers
|
||||
* to protect against DNS rebinding attacks.
|
||||
*
|
||||
* Install on a route to intercept all requests **before** handlers:
|
||||
* ```kotlin
|
||||
* route("/mcp") {
|
||||
* install(DnsRebindingProtection) {
|
||||
* allowedHosts = listOf("myapp.com", "localhost")
|
||||
* }
|
||||
* // handlers...
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
public val DnsRebindingProtection: RouteScopedPlugin<DnsRebindingProtectionConfig> =
|
||||
createRouteScopedPlugin(
|
||||
"MCP-DnsRebindingProtection",
|
||||
::DnsRebindingProtectionConfig,
|
||||
) {
|
||||
val hosts: Set<String> = pluginConfig.allowedHosts.mapTo(mutableSetOf()) {
|
||||
extractHostname(it)?.lowercase()
|
||||
?: error("Invalid host in DnsRebindingProtection.allowedHosts: '$it'")
|
||||
}
|
||||
val originHosts: Set<String>? = pluginConfig.allowedOrigins?.mapTo(mutableSetOf()) {
|
||||
extractOriginHost(it)?.lowercase()
|
||||
?: error("Invalid origin in DnsRebindingProtection.allowedOrigins: '$it'")
|
||||
}
|
||||
|
||||
onCall { call ->
|
||||
val hostHeader = call.request.header(HttpHeaders.Host)
|
||||
val hostname = hostHeader?.let { extractHostname(it) }?.lowercase()
|
||||
|
||||
if (hostname == null) {
|
||||
call.rejectDnsValidation("Invalid Host header: (malformed or missing)")
|
||||
return@onCall
|
||||
}
|
||||
if (hostname !in hosts) {
|
||||
call.rejectDnsValidation("Invalid Host: $hostname")
|
||||
return@onCall
|
||||
}
|
||||
|
||||
if (originHosts != null) {
|
||||
val originHeader = call.request.header(HttpHeaders.Origin)
|
||||
// Allow requests without Origin (non-browser clients cannot perform DNS rebinding)
|
||||
if (originHeader != null) {
|
||||
val originHost = extractOriginHost(originHeader)?.lowercase()
|
||||
if (originHost == null) {
|
||||
call.rejectDnsValidation("Invalid Origin header: (unparseable)")
|
||||
return@onCall
|
||||
}
|
||||
if (originHost !in originHosts) {
|
||||
call.rejectDnsValidation("Invalid Origin host: $originHost")
|
||||
return@onCall
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Responds with a 403 Forbidden JSON-RPC error without requiring ContentNegotiation.
|
||||
*/
|
||||
private suspend fun ApplicationCall.rejectDnsValidation(message: String) {
|
||||
val error = JSONRPCError(
|
||||
id = null,
|
||||
error = RPCError(
|
||||
code = -32_000,
|
||||
message = message,
|
||||
),
|
||||
)
|
||||
respondText(
|
||||
McpJson.encodeToString(error),
|
||||
ContentType.Application.Json,
|
||||
HttpStatusCode.Forbidden,
|
||||
)
|
||||
}
|
||||
|
|
@ -37,14 +37,26 @@ private val logger = KotlinLogging.logger {}
|
|||
* Use [Application.mcp] if you want SSE to be installed automatically.
|
||||
*
|
||||
* @param path the URL path to register the SSE endpoint.
|
||||
* @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]`.
|
||||
* @param allowedOrigins origins allowed in the `Origin` header, compared by hostname only
|
||||
* (scheme and port are ignored). Requests without an `Origin` header are allowed.
|
||||
* Pass `null` to skip origin validation.
|
||||
* @param block factory block with access to the [ServerSSESession]
|
||||
* that creates and returns the [Server] to handle the connection.
|
||||
* @throws IllegalStateException if the [SSE] plugin is not installed.
|
||||
*/
|
||||
@KtorDsl
|
||||
public fun Route.mcp(path: String, block: ServerSSESession.() -> Server) {
|
||||
@Suppress("LongParameterList")
|
||||
public fun Route.mcp(
|
||||
path: String,
|
||||
enableDnsRebindingProtection: Boolean = true,
|
||||
allowedHosts: List<String>? = null,
|
||||
allowedOrigins: List<String>? = null,
|
||||
block: ServerSSESession.() -> Server,
|
||||
) {
|
||||
route(path) {
|
||||
mcp(block)
|
||||
mcp(enableDnsRebindingProtection, allowedHosts, allowedOrigins, block)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -55,12 +67,22 @@ public fun Route.mcp(path: String, block: ServerSSESession.() -> Server) {
|
|||
* **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.
|
||||
*
|
||||
* @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]`.
|
||||
* @param allowedOrigins origins allowed in the `Origin` header, compared by hostname only
|
||||
* (scheme and port are ignored). Requests without an `Origin` header are allowed.
|
||||
* Pass `null` to skip origin validation.
|
||||
* @param block factory block with access to the [ServerSSESession]
|
||||
* that creates and returns the [Server] to handle the connection.
|
||||
* @throws IllegalStateException if the [SSE] plugin is not installed.
|
||||
*/
|
||||
@KtorDsl
|
||||
public fun Route.mcp(block: ServerSSESession.() -> Server) {
|
||||
public fun Route.mcp(
|
||||
enableDnsRebindingProtection: Boolean = true,
|
||||
allowedHosts: List<String>? = null,
|
||||
allowedOrigins: List<String>? = null,
|
||||
block: ServerSSESession.() -> Server,
|
||||
) {
|
||||
try {
|
||||
plugin(SSE)
|
||||
} catch (e: MissingApplicationPluginException) {
|
||||
|
|
@ -72,6 +94,8 @@ public fun Route.mcp(block: ServerSSESession.() -> Server) {
|
|||
)
|
||||
}
|
||||
|
||||
installDnsRebindingProtection(enableDnsRebindingProtection, allowedHosts, allowedOrigins)
|
||||
|
||||
val transportManager = TransportManager<SseServerTransport>()
|
||||
|
||||
sse {
|
||||
|
|
@ -91,21 +115,35 @@ public fun Route.mcp(block: ServerSSESession.() -> Server) {
|
|||
* Automatically installs [ContentNegotiation][io.ktor.server.plugins.contentnegotiation.ContentNegotiation]
|
||||
* with [McpJson][io.modelcontextprotocol.kotlin.sdk.types.McpJson] and [SSE].
|
||||
*
|
||||
* @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]`.
|
||||
* @param allowedOrigins origins allowed in the `Origin` header, compared by hostname only
|
||||
* (scheme and port are ignored). Requests without an `Origin` header are allowed.
|
||||
* Pass `null` to skip origin validation.
|
||||
* @param block factory block with access to the [ServerSSESession]
|
||||
* that creates and returns the [Server] to handle the connection.
|
||||
*/
|
||||
@KtorDsl
|
||||
public fun Application.mcp(block: ServerSSESession.() -> Server) {
|
||||
public fun Application.mcp(
|
||||
enableDnsRebindingProtection: Boolean = true,
|
||||
allowedHosts: List<String>? = null,
|
||||
allowedOrigins: List<String>? = null,
|
||||
block: ServerSSESession.() -> Server,
|
||||
) {
|
||||
installMcpContentNegotiation()
|
||||
install(SSE)
|
||||
|
||||
routing {
|
||||
mcp(block)
|
||||
mcp(enableDnsRebindingProtection, allowedHosts, allowedOrigins, block)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private fun Application.mcpStreamableHttp(
|
||||
path: String = "/mcp",
|
||||
enableDnsRebindingProtection: Boolean,
|
||||
allowedHosts: List<String>?,
|
||||
allowedOrigins: List<String>?,
|
||||
configuration: StreamableHttpServerTransport.Configuration,
|
||||
block: RoutingContext.() -> Server,
|
||||
) {
|
||||
|
|
@ -116,6 +154,8 @@ private fun Application.mcpStreamableHttp(
|
|||
|
||||
routing {
|
||||
route(path) {
|
||||
installDnsRebindingProtection(enableDnsRebindingProtection, allowedHosts, allowedOrigins)
|
||||
|
||||
// Set Mcp-Session-Id on GET responses before Ktor's sse {} commits headers.
|
||||
intercept(ApplicationCallPipeline.Plugins) {
|
||||
if (context.request.httpMethod == HttpMethod.Get) {
|
||||
|
|
@ -160,10 +200,12 @@ private fun Application.mcpStreamableHttp(
|
|||
* with [McpJson][io.modelcontextprotocol.kotlin.sdk.types.McpJson] and [SSE].
|
||||
*
|
||||
* @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 false.
|
||||
* @param allowedHosts A list of hostnames allowed to access the endpoint. If `null`, no restrictions are applied.
|
||||
* @param allowedOrigins A list of origins allowed to perform cross-origin requests (CORS).
|
||||
* If `null`, no restrictions are applied.
|
||||
* @param enableDnsRebindingProtection Enables DNS rebinding attack protection for the endpoint. Defaults to `true`.
|
||||
* @param allowedHosts A list of hostnames allowed to access the endpoint.
|
||||
* If `null` and DNS rebinding protection is enabled, defaults to `localhost`, `127.0.0.1`, `[::1]`.
|
||||
* @param allowedOrigins A list of allowed `Origin` header values, compared by hostname only
|
||||
* (scheme and port are ignored). Requests without an `Origin` header are allowed.
|
||||
* If `null`, origin validation is disabled.
|
||||
* @param eventStore An optional [EventStore] instance to enable resumable event stream functionality.
|
||||
* Allows storing and replaying events.
|
||||
* @param block factory block with access to the [RoutingContext] (for reading request headers)
|
||||
|
|
@ -172,7 +214,7 @@ private fun Application.mcpStreamableHttp(
|
|||
@KtorDsl
|
||||
public fun Application.mcpStreamableHttp(
|
||||
path: String = "/mcp",
|
||||
enableDnsRebindingProtection: Boolean = false,
|
||||
enableDnsRebindingProtection: Boolean = true,
|
||||
allowedHosts: List<String>? = null,
|
||||
allowedOrigins: List<String>? = null,
|
||||
eventStore: EventStore? = null,
|
||||
|
|
@ -180,10 +222,10 @@ public fun Application.mcpStreamableHttp(
|
|||
) {
|
||||
mcpStreamableHttp(
|
||||
path = path,
|
||||
enableDnsRebindingProtection = enableDnsRebindingProtection,
|
||||
allowedHosts = allowedHosts,
|
||||
allowedOrigins = allowedOrigins,
|
||||
configuration = StreamableHttpServerTransport.Configuration(
|
||||
enableDnsRebindingProtection = enableDnsRebindingProtection,
|
||||
allowedHosts = allowedHosts,
|
||||
allowedOrigins = allowedOrigins,
|
||||
eventStore = eventStore,
|
||||
enableJsonResponse = true,
|
||||
),
|
||||
|
|
@ -191,8 +233,12 @@ public fun Application.mcpStreamableHttp(
|
|||
)
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
private fun Application.mcpStatelessStreamableHttp(
|
||||
path: String = "/mcp",
|
||||
enableDnsRebindingProtection: Boolean,
|
||||
allowedHosts: List<String>?,
|
||||
allowedOrigins: List<String>?,
|
||||
configuration: StreamableHttpServerTransport.Configuration,
|
||||
block: RoutingContext.() -> Server,
|
||||
) {
|
||||
|
|
@ -201,6 +247,8 @@ private fun Application.mcpStatelessStreamableHttp(
|
|||
|
||||
routing {
|
||||
route(path) {
|
||||
installDnsRebindingProtection(enableDnsRebindingProtection, allowedHosts, allowedOrigins)
|
||||
|
||||
post {
|
||||
mcpStatelessStreamableHttpEndpoint(
|
||||
configuration = configuration,
|
||||
|
|
@ -236,9 +284,12 @@ private fun Application.mcpStatelessStreamableHttp(
|
|||
* with [McpJson][io.modelcontextprotocol.kotlin.sdk.types.McpJson] and [SSE].
|
||||
*
|
||||
* @param path The URL path where the server listens for incoming JSON-RPC requests. Defaults to "/mcp".
|
||||
* @param enableDnsRebindingProtection Determines whether DNS rebinding protection is enabled. Defaults to `false`.
|
||||
* @param allowedHosts A list of allowed hostnames. If null, host filtering is disabled.
|
||||
* @param allowedOrigins A list of allowed origins for CORS. If null, origin filtering is disabled.
|
||||
* @param enableDnsRebindingProtection Determines whether DNS rebinding protection is enabled. Defaults to `true`.
|
||||
* @param allowedHosts A list of allowed hostnames. If `null` and DNS rebinding protection is enabled,
|
||||
* defaults to `localhost`, `127.0.0.1`, `[::1]`.
|
||||
* @param allowedOrigins A list of allowed `Origin` header values, compared by hostname only
|
||||
* (scheme and port are ignored). Requests without an `Origin` header are allowed.
|
||||
* If `null`, origin validation is disabled.
|
||||
* @param eventStore An optional [EventStore] implementation to provide resumability and event replay support.
|
||||
* @param block factory block with access to the [RoutingContext] (for reading request headers)
|
||||
* that creates and returns the [Server] to handle the connection.
|
||||
|
|
@ -246,7 +297,7 @@ private fun Application.mcpStatelessStreamableHttp(
|
|||
@KtorDsl
|
||||
public fun Application.mcpStatelessStreamableHttp(
|
||||
path: String = "/mcp",
|
||||
enableDnsRebindingProtection: Boolean = false,
|
||||
enableDnsRebindingProtection: Boolean = true,
|
||||
allowedHosts: List<String>? = null,
|
||||
allowedOrigins: List<String>? = null,
|
||||
eventStore: EventStore? = null,
|
||||
|
|
@ -254,10 +305,10 @@ public fun Application.mcpStatelessStreamableHttp(
|
|||
) {
|
||||
mcpStatelessStreamableHttp(
|
||||
path = path,
|
||||
enableDnsRebindingProtection = enableDnsRebindingProtection,
|
||||
allowedHosts = allowedHosts,
|
||||
allowedOrigins = allowedOrigins,
|
||||
configuration = StreamableHttpServerTransport.Configuration(
|
||||
enableDnsRebindingProtection = enableDnsRebindingProtection,
|
||||
allowedHosts = allowedHosts,
|
||||
allowedOrigins = allowedOrigins,
|
||||
eventStore = eventStore,
|
||||
enableJsonResponse = true,
|
||||
),
|
||||
|
|
@ -395,3 +446,11 @@ private suspend fun RoutingContext.streamableTransport(
|
|||
|
||||
return transport
|
||||
}
|
||||
|
||||
private fun Route.installDnsRebindingProtection(enabled: Boolean, hosts: List<String>?, origins: List<String>?) {
|
||||
if (!enabled) return
|
||||
install(DnsRebindingProtection) {
|
||||
allowedHosts = hosts ?: LOCALHOST_ALLOWED_HOSTS
|
||||
origins?.let { allowedOrigins = it }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,8 +136,20 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
|
|||
*/
|
||||
public class Configuration(
|
||||
public val enableJsonResponse: Boolean = false,
|
||||
@Deprecated(
|
||||
message = "Use install(DnsRebindingProtection) on your Ktor route instead",
|
||||
level = DeprecationLevel.WARNING,
|
||||
)
|
||||
public val enableDnsRebindingProtection: Boolean = false,
|
||||
@Deprecated(
|
||||
message = "Use install(DnsRebindingProtection) on your Ktor route instead",
|
||||
level = DeprecationLevel.WARNING,
|
||||
)
|
||||
public val allowedHosts: List<String>? = null,
|
||||
@Deprecated(
|
||||
message = "Use install(DnsRebindingProtection) on your Ktor route instead",
|
||||
level = DeprecationLevel.WARNING,
|
||||
)
|
||||
public val allowedOrigins: List<String>? = null,
|
||||
public val eventStore: EventStore? = null,
|
||||
public val retryInterval: Duration? = null,
|
||||
|
|
@ -300,7 +312,12 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
|
|||
}
|
||||
|
||||
/**
|
||||
* Handles an incoming HTTP request, whether GET, POST or DELETE
|
||||
* Handles an incoming HTTP request, whether GET, POST or DELETE.
|
||||
*
|
||||
* **Note:** this method does not perform DNS rebinding protection on its own.
|
||||
* When using this transport outside the [mcpStreamableHttp] / [mcpStatelessStreamableHttp]
|
||||
* DSL, install the [DnsRebindingProtection] plugin on your Ktor route
|
||||
* (or apply equivalent middleware) to validate `Host` / `Origin` headers.
|
||||
*/
|
||||
public suspend fun handleRequest(session: ServerSSESession?, call: ApplicationCall) {
|
||||
validateHeaders(call)?.let { reason ->
|
||||
|
|
@ -651,10 +668,13 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
|
|||
if (!configuration.enableDnsRebindingProtection) return null
|
||||
|
||||
configuration.allowedHosts?.let { hosts ->
|
||||
val hostHeader = call.request.headers[HttpHeaders.Host]?.lowercase()
|
||||
val allowedHostsLowercase = hosts.map { it.lowercase() }
|
||||
val hostHeader = call.request.headers[HttpHeaders.Host]
|
||||
val hostname = hostHeader?.let { extractHostname(it) }?.lowercase()
|
||||
val allowedHostsLowercase = hosts.map {
|
||||
extractHostname(it)?.lowercase() ?: it.lowercase()
|
||||
}
|
||||
|
||||
if (hostHeader == null || hostHeader !in allowedHostsLowercase) {
|
||||
if (hostname == null || hostname !in allowedHostsLowercase) {
|
||||
return "Invalid Host header: $hostHeader"
|
||||
}
|
||||
}
|
||||
|
|
@ -663,7 +683,8 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
|
|||
val originHeader = call.request.headers[HttpHeaders.Origin]?.lowercase()
|
||||
val allowedOriginsLowercase = origins.map { it.lowercase() }
|
||||
|
||||
if (originHeader == null || originHeader !in allowedOriginsLowercase) {
|
||||
// Allow requests without Origin (non-browser clients cannot perform DNS rebinding)
|
||||
if (originHeader != null && originHeader !in allowedOriginsLowercase) {
|
||||
return "Invalid Origin header: $originHeader"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,382 @@
|
|||
package io.modelcontextprotocol.kotlin.sdk.server
|
||||
|
||||
import io.kotest.assertions.ktor.client.shouldHaveStatus
|
||||
import io.kotest.assertions.throwables.shouldThrow
|
||||
import io.kotest.matchers.shouldBe
|
||||
import io.kotest.matchers.string.shouldContain
|
||||
import io.kotest.matchers.string.shouldNotContain
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.post
|
||||
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
|
||||
import io.ktor.server.response.respondText
|
||||
import io.ktor.server.routing.post
|
||||
import io.ktor.server.routing.route
|
||||
import io.ktor.server.routing.routing
|
||||
import io.ktor.server.sse.SSE
|
||||
import io.ktor.server.testing.ApplicationTestBuilder
|
||||
import io.ktor.server.testing.testApplication
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.Implementation
|
||||
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
|
||||
import org.junit.jupiter.params.ParameterizedTest
|
||||
import org.junit.jupiter.params.provider.Arguments
|
||||
import org.junit.jupiter.params.provider.MethodSource
|
||||
import org.junit.jupiter.params.provider.ValueSource
|
||||
import kotlin.test.Test
|
||||
|
||||
class DnsRebindingProtectionTest {
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun invalidHostCases(): List<Arguments> = listOf(
|
||||
Arguments.of("", "Invalid Host header: (malformed or missing)"),
|
||||
Arguments.of("evil.com", "Invalid Host: evil.com"),
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun extractHostnameAcceptCases(): List<Arguments> = listOf(
|
||||
Arguments.of("localhost", "localhost"),
|
||||
Arguments.of("localhost:3000", "localhost"),
|
||||
Arguments.of("127.0.0.1:8080", "127.0.0.1"),
|
||||
Arguments.of("[::1]", "[::1]"),
|
||||
Arguments.of("[::1]:3000", "[::1]"),
|
||||
Arguments.of("localhost:", "localhost"),
|
||||
Arguments.of("[::1]:", "[::1]"),
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun extractHostnameRejectCases(): List<String> = listOf(
|
||||
"", // empty
|
||||
"evil.com@localhost", // userinfo
|
||||
"evil.com/path", // path
|
||||
"evil.com?q=1", // query
|
||||
"evil.com#frag", // fragment
|
||||
"[::1", // malformed IPv6
|
||||
"[]", // empty brackets
|
||||
"[]:3000", // empty brackets + port
|
||||
" localhost", // leading whitespace
|
||||
"localhost ", // trailing whitespace
|
||||
"localhost\t:80", // embedded tab
|
||||
"localhost:abc", // non-numeric port
|
||||
"localhost:-1", // negative port
|
||||
"[::1]:abc", // non-numeric IPv6 port
|
||||
":80", // leading colon
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun extractOriginHostAcceptCases(): List<Arguments> = listOf(
|
||||
Arguments.of("http://example.com", "example.com"),
|
||||
Arguments.of("http://example.com:8080", "example.com"),
|
||||
Arguments.of("https://Example.COM", "Example.COM"),
|
||||
Arguments.of("https://example.com", "example.com"),
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun extractOriginHostRejectCases(): List<String> = listOf(
|
||||
"example.com", // no scheme
|
||||
"not-a-url", // unparseable
|
||||
"", // empty
|
||||
)
|
||||
}
|
||||
|
||||
private fun testWithPlugin(
|
||||
config: DnsRebindingProtectionConfig.() -> Unit = {},
|
||||
test: suspend ApplicationTestBuilder.() -> Unit,
|
||||
): Unit = testApplication {
|
||||
application {
|
||||
install(SSE)
|
||||
routing {
|
||||
route("/mcp") {
|
||||
install(DnsRebindingProtection, config)
|
||||
post { call.respondText("ok") }
|
||||
}
|
||||
}
|
||||
}
|
||||
test()
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(strings = ["localhost", "127.0.0.1", "[::1]", "localhost:3000", "[::1]:8080"])
|
||||
fun `plugin accepts valid Host header`(hostHeader: String) = testWithPlugin {
|
||||
val response = client.post("/mcp") {
|
||||
header(HttpHeaders.Host, hostHeader)
|
||||
}
|
||||
response.shouldHaveStatus(HttpStatusCode.OK)
|
||||
response.bodyAsText() shouldBe "ok"
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("invalidHostCases")
|
||||
fun `plugin rejects invalid Host header`(hostHeader: String, expectedBody: String) = testWithPlugin {
|
||||
val response = client.post("/mcp") {
|
||||
header(HttpHeaders.Host, hostHeader)
|
||||
}
|
||||
response.shouldHaveStatus(HttpStatusCode.Forbidden)
|
||||
response.bodyAsText() shouldContain expectedBody
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plugin does not echo raw malformed Host in rejection`() = testWithPlugin {
|
||||
val response = client.post("/mcp") {
|
||||
header(HttpHeaders.Host, "evil.com@localhost")
|
||||
}
|
||||
response.shouldHaveStatus(HttpStatusCode.Forbidden)
|
||||
response.bodyAsText() shouldNotContain "evil.com@localhost"
|
||||
response.bodyAsText() shouldContain "malformed or missing"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plugin allows request without Origin header`() = testWithPlugin(
|
||||
config = { allowedOrigins = listOf("http://localhost:3000") },
|
||||
) {
|
||||
val response = client.post("/mcp") {
|
||||
header(HttpHeaders.Host, "localhost")
|
||||
}
|
||||
response.shouldHaveStatus(HttpStatusCode.OK)
|
||||
response.bodyAsText() shouldBe "ok"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plugin rejects disallowed Origin`() = testWithPlugin(
|
||||
config = { allowedOrigins = listOf("http://localhost:3000") },
|
||||
) {
|
||||
val response = client.post("/mcp") {
|
||||
header(HttpHeaders.Host, "localhost")
|
||||
header(HttpHeaders.Origin, "http://evil.com")
|
||||
}
|
||||
response.shouldHaveStatus(HttpStatusCode.Forbidden)
|
||||
response.bodyAsText() shouldContain "Invalid Origin host: evil.com"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plugin allows matching Origin`() = testWithPlugin(
|
||||
config = { allowedOrigins = listOf("http://localhost:3000") },
|
||||
) {
|
||||
val response = client.post("/mcp") {
|
||||
header(HttpHeaders.Host, "localhost")
|
||||
header(HttpHeaders.Origin, "http://localhost:3000")
|
||||
}
|
||||
response.shouldHaveStatus(HttpStatusCode.OK)
|
||||
response.bodyAsText() shouldBe "ok"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plugin allows Origin with different port but same hostname`() = testWithPlugin(
|
||||
config = { allowedOrigins = listOf("http://localhost:3000") },
|
||||
) {
|
||||
val response = client.post("/mcp") {
|
||||
header(HttpHeaders.Host, "localhost")
|
||||
header(HttpHeaders.Origin, "http://localhost:9999")
|
||||
}
|
||||
response.shouldHaveStatus(HttpStatusCode.OK)
|
||||
response.bodyAsText() shouldBe "ok"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plugin allows Origin with different scheme but same hostname`() = testWithPlugin(
|
||||
config = { allowedOrigins = listOf("http://localhost:3000") },
|
||||
) {
|
||||
val response = client.post("/mcp") {
|
||||
header(HttpHeaders.Host, "localhost")
|
||||
header(HttpHeaders.Origin, "https://localhost:3000")
|
||||
}
|
||||
response.shouldHaveStatus(HttpStatusCode.OK)
|
||||
response.bodyAsText() shouldBe "ok"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plugin rejects unparseable Origin header`() = testWithPlugin(
|
||||
config = { allowedOrigins = listOf("http://localhost:3000") },
|
||||
) {
|
||||
val response = client.post("/mcp") {
|
||||
header(HttpHeaders.Host, "localhost")
|
||||
header(HttpHeaders.Origin, "not-a-url")
|
||||
}
|
||||
response.shouldHaveStatus(HttpStatusCode.Forbidden)
|
||||
response.bodyAsText() shouldContain "Invalid Origin header: (unparseable)"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plugin with custom allowedHosts accepts matching host`() = testWithPlugin(
|
||||
config = { allowedHosts = listOf("myapp.com") },
|
||||
) {
|
||||
val response = client.post("/mcp") {
|
||||
header(HttpHeaders.Host, "myapp.com:443")
|
||||
}
|
||||
response.shouldHaveStatus(HttpStatusCode.OK)
|
||||
response.bodyAsText() shouldBe "ok"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plugin with custom allowedHosts rejects non-matching host`() = testWithPlugin(
|
||||
config = { allowedHosts = listOf("myapp.com") },
|
||||
) {
|
||||
val response = client.post("/mcp") {
|
||||
header(HttpHeaders.Host, "localhost")
|
||||
}
|
||||
response.shouldHaveStatus(HttpStatusCode.Forbidden)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `host validation is case insensitive`() = testWithPlugin(
|
||||
config = { allowedHosts = listOf("MyApp.COM") },
|
||||
) {
|
||||
val response = client.post("/mcp") {
|
||||
header(HttpHeaders.Host, "myapp.com")
|
||||
}
|
||||
response.shouldHaveStatus(HttpStatusCode.OK)
|
||||
response.bodyAsText() shouldBe "ok"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `origin validation is case insensitive`() = testWithPlugin(
|
||||
config = {
|
||||
allowedHosts = listOf("localhost")
|
||||
allowedOrigins = listOf("https://MyApp.COM")
|
||||
},
|
||||
) {
|
||||
val response = client.post("/mcp") {
|
||||
header(HttpHeaders.Host, "localhost")
|
||||
header(HttpHeaders.Origin, "https://myapp.com")
|
||||
}
|
||||
response.shouldHaveStatus(HttpStatusCode.OK)
|
||||
response.bodyAsText() shouldBe "ok"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plugin with empty allowedHosts rejects all requests`() = testWithPlugin(
|
||||
config = { allowedHosts = emptyList() },
|
||||
) {
|
||||
val localhostResponse = client.post("/mcp") {
|
||||
header(HttpHeaders.Host, "localhost")
|
||||
}
|
||||
localhostResponse.shouldHaveStatus(HttpStatusCode.Forbidden)
|
||||
localhostResponse.bodyAsText() shouldContain "Invalid Host: localhost"
|
||||
|
||||
val otherResponse = client.post("/mcp") {
|
||||
header(HttpHeaders.Host, "myapp.com")
|
||||
}
|
||||
otherResponse.shouldHaveStatus(HttpStatusCode.Forbidden)
|
||||
otherResponse.bodyAsText() shouldContain "Invalid Host: myapp.com"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plugin fails to install when allowedHosts contains an invalid host`() {
|
||||
val ex = shouldThrow<IllegalStateException> {
|
||||
testWithPlugin(
|
||||
config = { allowedHosts = listOf("https://example.com") },
|
||||
) {
|
||||
client.post("/mcp") { header(HttpHeaders.Host, "example.com") }
|
||||
}
|
||||
}
|
||||
ex.message shouldContain "https://example.com"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plugin fails to install when allowedOrigins contains an invalid origin`() {
|
||||
val ex = shouldThrow<IllegalStateException> {
|
||||
testWithPlugin(
|
||||
config = { allowedOrigins = listOf("not-a-url") },
|
||||
) {
|
||||
client.post("/mcp") { header(HttpHeaders.Host, "localhost") }
|
||||
}
|
||||
}
|
||||
ex.message shouldContain "not-a-url"
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Route mcp with DNS protection enabled rejects non-localhost`() = testApplication {
|
||||
application {
|
||||
install(SSE)
|
||||
routing {
|
||||
mcp { testServer() }
|
||||
}
|
||||
}
|
||||
|
||||
val response = client.post("/") {
|
||||
header(HttpHeaders.Host, "evil.com")
|
||||
contentType(ContentType.Application.Json)
|
||||
}
|
||||
response.shouldHaveStatus(HttpStatusCode.Forbidden)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Route mcp with DNS protection disabled allows any host`() = testApplication {
|
||||
application {
|
||||
install(SSE)
|
||||
routing {
|
||||
mcp(enableDnsRebindingProtection = false) { testServer() }
|
||||
}
|
||||
}
|
||||
|
||||
val response = client.post("/") {
|
||||
header(HttpHeaders.Host, "evil.com")
|
||||
contentType(ContentType.Application.Json)
|
||||
}
|
||||
// Not 403 — the request reaches the handler (may get 400 for missing sessionId, etc.)
|
||||
response.shouldHaveStatus(HttpStatusCode.BadRequest)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mcpStreamableHttp rejects non-localhost Host by default`() = testApplication {
|
||||
application {
|
||||
mcpStreamableHttp { testServer() }
|
||||
}
|
||||
|
||||
val response = client.post("/mcp") {
|
||||
header(HttpHeaders.Host, "evil.com")
|
||||
contentType(ContentType.Application.Json)
|
||||
}
|
||||
response.shouldHaveStatus(HttpStatusCode.Forbidden)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mcpStatelessStreamableHttp rejects non-localhost Host by default`() = testApplication {
|
||||
application {
|
||||
mcpStatelessStreamableHttp { testServer() }
|
||||
}
|
||||
|
||||
val response = client.post("/mcp") {
|
||||
header(HttpHeaders.Host, "evil.com")
|
||||
contentType(ContentType.Application.Json)
|
||||
}
|
||||
response.shouldHaveStatus(HttpStatusCode.Forbidden)
|
||||
}
|
||||
|
||||
// -- extractHostname unit tests --
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("extractHostnameAcceptCases")
|
||||
fun `extractHostname accepts valid host`(input: String, expected: String) {
|
||||
extractHostname(input) shouldBe expected
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("extractHostnameRejectCases")
|
||||
fun `extractHostname rejects invalid host`(input: String) {
|
||||
extractHostname(input) shouldBe null
|
||||
}
|
||||
|
||||
// -- extractOriginHost unit tests --
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("extractOriginHostAcceptCases")
|
||||
fun `extractOriginHost extracts hostname`(input: String, expected: String) {
|
||||
extractOriginHost(input) shouldBe expected
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@MethodSource("extractOriginHostRejectCases")
|
||||
fun `extractOriginHost returns null for invalid origin`(input: String) {
|
||||
extractOriginHost(input) shouldBe null
|
||||
}
|
||||
|
||||
private fun testServer(): Server = Server(
|
||||
serverInfo = Implementation(name = "test-server", version = "1.0.0"),
|
||||
options = ServerOptions(capabilities = ServerCapabilities()),
|
||||
)
|
||||
}
|
||||
|
|
@ -24,9 +24,9 @@ class KtorApplicationExtensionsTest : AbstractKtorExtensionsTest() {
|
|||
* added to the same application.
|
||||
*/
|
||||
@Test
|
||||
fun `Application mcp should installs SSE and coexist with other routes`() = testApplication {
|
||||
fun `Application mcp should install SSE and coexist with other routes`() = testApplication {
|
||||
application {
|
||||
mcp { testServer() }
|
||||
mcp(enableDnsRebindingProtection = false) { testServer() }
|
||||
|
||||
routing {
|
||||
get("/health") { call.respondText("healthy") }
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ class KtorRouteExtensionsTest : AbstractKtorExtensionsTest() {
|
|||
|
||||
route("/api/mcp") {
|
||||
get("/test") { call.respondText("test-endpoint") }
|
||||
mcp { testServer() }
|
||||
mcp(enableDnsRebindingProtection = false) { testServer() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -91,7 +91,7 @@ class KtorRouteExtensionsTest : AbstractKtorExtensionsTest() {
|
|||
route("/v1") {
|
||||
route("/services") {
|
||||
route("/mcp") {
|
||||
mcp { testServer() }
|
||||
mcp(enableDnsRebindingProtection = false) { testServer() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -112,7 +112,7 @@ class KtorRouteExtensionsTest : AbstractKtorExtensionsTest() {
|
|||
|
||||
routing {
|
||||
route("/api") {
|
||||
mcp("/mcp-endpoint") { testServer() }
|
||||
mcp("/mcp-endpoint", enableDnsRebindingProtection = false) { testServer() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -477,6 +477,7 @@ class StreamableHttpServerTransportTest {
|
|||
|
||||
// Step 1: Initialize session via POST
|
||||
val initResponse = client.post(mcpPath) {
|
||||
header(HttpHeaders.Host, "localhost")
|
||||
addStreamableHeaders()
|
||||
setBody(buildInitializeRequestPayload())
|
||||
}
|
||||
|
|
@ -485,6 +486,7 @@ class StreamableHttpServerTransportTest {
|
|||
|
||||
// Step 2: Open first GET SSE stream
|
||||
client.prepareGet(mcpPath) {
|
||||
header(HttpHeaders.Host, "localhost")
|
||||
header(HttpHeaders.Accept, ContentType.Text.EventStream.toString())
|
||||
header(MCP_SESSION_ID_HEADER, sessionId)
|
||||
header("mcp-protocol-version", LATEST_PROTOCOL_VERSION)
|
||||
|
|
@ -495,6 +497,7 @@ class StreamableHttpServerTransportTest {
|
|||
// Step 3: Open a second GET — the transport closes the old session
|
||||
// and the new stream takes over.
|
||||
client.prepareGet(mcpPath) {
|
||||
header(HttpHeaders.Host, "localhost")
|
||||
header(HttpHeaders.Accept, ContentType.Text.EventStream.toString())
|
||||
header(MCP_SESSION_ID_HEADER, sessionId)
|
||||
header("mcp-protocol-version", LATEST_PROTOCOL_VERSION)
|
||||
|
|
@ -528,6 +531,7 @@ class StreamableHttpServerTransportTest {
|
|||
|
||||
// Step 1: Initialize session via POST
|
||||
val initResponse = client.post(mcpPath) {
|
||||
header(HttpHeaders.Host, "localhost")
|
||||
addStreamableHeaders()
|
||||
setBody(buildInitializeRequestPayload())
|
||||
}
|
||||
|
|
@ -536,6 +540,7 @@ class StreamableHttpServerTransportTest {
|
|||
|
||||
// Step 2: Open and then close a GET SSE stream
|
||||
client.prepareGet(mcpPath) {
|
||||
header(HttpHeaders.Host, "localhost")
|
||||
header(HttpHeaders.Accept, ContentType.Text.EventStream.toString())
|
||||
header(MCP_SESSION_ID_HEADER, sessionId)
|
||||
header("mcp-protocol-version", LATEST_PROTOCOL_VERSION)
|
||||
|
|
@ -547,6 +552,7 @@ class StreamableHttpServerTransportTest {
|
|||
// Step 3: Immediately reconnect — the transport should close the stale
|
||||
// stream and allow the new one.
|
||||
client.prepareGet(mcpPath) {
|
||||
header(HttpHeaders.Host, "localhost")
|
||||
header(HttpHeaders.Accept, ContentType.Text.EventStream.toString())
|
||||
header(MCP_SESSION_ID_HEADER, sessionId)
|
||||
header("mcp-protocol-version", LATEST_PROTOCOL_VERSION)
|
||||
|
|
@ -578,6 +584,7 @@ class StreamableHttpServerTransportTest {
|
|||
|
||||
// Step 1: Initialize session via POST
|
||||
val initResponse = client.post(mcpPath) {
|
||||
header(HttpHeaders.Host, "localhost")
|
||||
addStreamableHeaders()
|
||||
setBody(buildInitializeRequestPayload())
|
||||
}
|
||||
|
|
@ -586,6 +593,7 @@ class StreamableHttpServerTransportTest {
|
|||
|
||||
// Step 2: Open GET SSE stream with session ID
|
||||
client.prepareGet(mcpPath) {
|
||||
header(HttpHeaders.Host, "localhost")
|
||||
header(HttpHeaders.Accept, ContentType.Text.EventStream.toString())
|
||||
header(MCP_SESSION_ID_HEADER, sessionId)
|
||||
header("mcp-protocol-version", LATEST_PROTOCOL_VERSION)
|
||||
|
|
|
|||
Loading…
Reference in a new issue