Skip to content
Open
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 @@ -19,8 +19,6 @@ fun main() {
json(McpJson)
}
mcpStreamableHttp(
enableDnsRebindingProtection = true,
allowedHosts = listOf("localhost", "127.0.0.1", "localhost:$port", "127.0.0.1:$port"),
eventStore = InMemoryEventStore(),
) {
createConformanceServer()
Expand Down
21 changes: 18 additions & 3 deletions kotlin-sdk-server/api/kotlin-sdk-server.api
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package io.modelcontextprotocol.kotlin.sdk.server

import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when input is "[]", indexOf(']') returns 1, tail is empty, and the function returns "[]". While this won't match any real allowedHosts entry (so no bypass), an attacker probing the parser with malformed headers gets inconsistent treatment (other malformed inputs return null). The TypeScript SDK rejects empty brackets.

Recommendation: Add a guard: if (end <= 1) null (bracket pair must contain at least one character).

Test to add:

@Test                                                                                                                                     
fun `extractHostname rejects empty IPv6 brackets`() {                                                                                       
    extractHostname("[]") shouldBe null                                                                                                   
}                                      

hostHeader.isBlank() -> null

hostHeader.any { it in FORBIDDEN_HOST_CHARS || it.isWhitespace() } -> null

hostHeader.startsWith("[") -> {
val end = hostHeader.indexOf(']')
val tail = if (end > 0) hostHeader.substring(end + 1) else ""
if (end > 0 && (tail.isEmpty() || tail.startsWith(':'))) {
hostHeader.substring(0, end + 1)
} else {
null
Comment on lines +52 to +55
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extractHostname claims to accept strict host[:port] / [ipv6][:port] per RFC 7230, but the IPv6 branch currently accepts values like [::1]: or [::1]:abc because it only checks tail.startsWith(':') (without validating that a non-empty port is numeric). This makes the parser more permissive than documented; consider rejecting any non-empty tail that isn’t :<digits> (and rejecting a trailing : with no digits).

Suggested change
if (end > 0 && (tail.isEmpty() || tail.startsWith(':'))) {
hostHeader.substring(0, end + 1)
} else {
null
if (end <= 0) {
null
} else if (tail.isEmpty()) {
hostHeader.substring(0, end + 1)
} else if (tail[0] != ':') {
null
} else {
val port = tail.substring(1)
if (port.isNotEmpty() && port.all { it.isDigit() }) {
hostHeader.substring(0, end + 1)
} else {
null
}

Copilot uses AI. Check for mistakes.
}
}

else -> hostHeader.substringBefore(':').ifEmpty { null }
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In extractHostname, the non-IPv6 branch uses substringBefore(':'), which will treat malformed Host headers like localhost: or example.com:abc as valid (hostname extracted as localhost / example.com). If the intent is RFC-7230 strictness (as the KDoc says), validate that when a : is present the port part is non-empty and all digits; otherwise return null.

Suggested change
else -> hostHeader.substringBefore(':').ifEmpty { null }
else -> {
val colonIndex = hostHeader.indexOf(':')
if (colonIndex < 0) {
hostHeader
} else {
val host = hostHeader.substring(0, colonIndex)
val port = hostHeader.substring(colonIndex + 1)
if (host.isNotEmpty() && port.isNotEmpty() && port.all { it.isDigit() }) {
host
} else {
null
}
}
}

Copilot uses AI. Check for mistakes.
}

/**
* 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_ALLOWED_HOSTS].
* An empty list will reject **all** requests.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you add a test for this case?

* @property allowedOrigins Optional list of allowed `Origin` header values.
Comment on lines +63 to +69
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DnsRebindingProtectionConfig KDoc says it defaults to [LOCALHOST_ALLOWED_HOSTS], but that constant is internal, so it’s not visible to consumers and may produce a broken link in generated docs. Consider making the default constant public or spelling out the default hostnames directly in the KDoc.

Copilot uses AI. Check for mistakes.
* If `null`, origin validation is disabled.
* If configured, requests **with** an `Origin` header 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()) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep processed configuration issutable:
pluginConfig.allowedHosts.map { ... }.toImmutableSet()

extractHostname(it)?.lowercase() ?: it.lowercase()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Transformations should be limited to validation and the lowercasing of plugin configuration. They should not conceal configuration errors which should be addressed by the user. For example, replace extractHostname with validateHostname and fail on error.

}
val origins: Set<String>? = pluginConfig.allowedOrigins?.mapTo(mutableSetOf()) { it.lowercase() }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's keep processed configuration immutable

Suggested change
val origins: Set<String>? = pluginConfig.allowedOrigins?.mapTo(mutableSetOf()) { it.lowercase() }
val origins: Set<String>? = pluginConfig.allowedOrigins?.map { it.lowercase() }?.toImmutableSet()


onCall { call ->
val hostHeader = call.request.header(HttpHeaders.Host)
val hostname = hostHeader?.let { extractHostname(it) }?.lowercase()

if (hostname == null || hostname !in hosts) {
call.rejectDnsValidation("Invalid Host header: $hostHeader")
return@onCall
}

if (origins != null) {
val origin = call.request.header(HttpHeaders.Origin)?.lowercase()
// Allow requests without Origin (non-browser clients cannot perform DNS rebinding)
if (origin != null && origin !in origins) {
call.rejectDnsValidation("Invalid Origin header: $origin")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would parse origin and use truncate everything except hostname. we may use "hostname*" (* for excluded characters)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, let's also parse Url from the Origin header using standard Url parser or use Ktor's parseUrl(...), because reimplementing RFC is hard. Then extract the hostname, e.g.:

fun extractHostFromOrigin(origin: String): String? {
    return runCatching {
        // Url is Ktor's built-in parser
        Url(origin).host.lowercase()
    }.getOrNull()
}

return@onCall
}
Comment on lines +107 to +118
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Error response leaks raw Host/Origin header values

Category: Security | Severity: Medium

HostValidation.kt:110 — "Invalid Host header: $hostHeader" and line 118 — "Invalid Origin header: $origin" echo attacker-controlled values directly into the JSON-RPC error response. While these are not rendered in a browser HTML context (Content-Type is application/json), reflecting unsanitized input is a code smell that can become exploitable if response bodies are logged to HTML dashboards or if content-type sniffing occurs.

Recommendation: Omit the raw header value from the error message, or truncate/sanitize it: call.rejectDnsValidation("Host header is not in the allowed hosts list")

}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HostValidation.kt:117 compares the Origin header as a raw lowercase string. The Origin header format is scheme://host[:port]. A full string match means allowedOrigins = listOf("http://localhost:3000") won't match HTTP://localhost:3000 (uppercase scheme). The hosts comparison correctly lowercases, but origins also need this — and do get it (line 103/115 both lowercase). However, there's no normalization of trailing slashes, default port stripping, or scheme decomposition.

Recommendation: Add a KDoc note on allowedOrigins that comparison is exact (case-insensitive) string match with no URL normalization.

}
}

/**
* 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 = RPCError.ErrorCode.CONNECTION_CLOSED,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

claude suggested to use the same code as in typescript sdk (-32000)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RPCError.ErrorCode.CONNECTION_CLOSED is semantically incorrect — the connection isn't closed, the request is forbidden. A more appropriate code would be a custom error or INVALID_REQUEST (-32600). This also means monitoring/alerting that keys on CONNECTION_CLOSED will conflate real connection drops with blocked DNS rebinding attempts.

message = message,
),
)
respondText(
McpJson.encodeToString(error),
ContentType.Application.Json,
HttpStatusCode.Forbidden,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,24 @@ 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_ALLOWED_HOSTS].
* @param allowedOrigins origins allowed in the `Origin` header, or `null` to skip origin validation.
Comment on lines 37 to +40
Copy link

Copilot AI Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Public KDoc references [LOCALHOST_ALLOWED_HOSTS], but LOCALHOST_ALLOWED_HOSTS is internal (in HostValidation.kt), so this link/value won’t be accessible to API consumers and may produce broken Dokka links. Consider either making the constant public (if you want users to reuse it) or updating the KDoc to state the default values explicitly (e.g., localhost, 127.0.0.1, [::1]) without linking to an internal symbol.

Copilot uses AI. Check for mistakes.
* @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,
Comment on lines +50 to +51
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

allowedHosts with null vs empty list have different semantics — not tested.

Category: Coverage gap

In KtorServer.kt:93, allowedHosts ?: LOCALHOST_ALLOWED_HOSTS means null falls back to localhost defaults, but emptyList() would mean "reject everything". The DnsRebindingProtectionConfig KDoc documents this (line 69: "An empty list will reject all requests"), but there's no test verifying this critical behavioral difference.

Test to add:

@Test
fun `plugin with empty allowedHosts rejects all requests`() = testWithPlugin(
    config = { allowedHosts = emptyList() },                                 
) {                                                                                                                                         
    val response = client.post("/mcp") {
        header(HttpHeaders.Host, "localhost")                                                                                               
    }                                                                                                                                     
    response.shouldHaveStatus(HttpStatusCode.Forbidden)                                                                                     
}

Comment on lines +50 to +51
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our objective is to verify that requests are accepted only from whitelisted hosts or from any host in the case of ”*”.
Should we use two parameters here, for allowed hosts and origins separately, or is a single host list sufficient?

block: ServerSSESession.() -> Server,
) {
route(path) {
mcp(block)
mcp(enableDnsRebindingProtection, allowedHosts, allowedOrigins, block)
}
}

Expand All @@ -53,12 +63,20 @@ 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_ALLOWED_HOSTS].
* @param allowedOrigins origins allowed in the `Origin` header, or `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) {
Expand All @@ -70,6 +88,13 @@ public fun Route.mcp(block: ServerSSESession.() -> Server) {
)
}

if (enableDnsRebindingProtection) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this pattern repeats multiple times, can be private utility method

install(DnsRebindingProtection) {
this.allowedHosts = allowedHosts ?: LOCALHOST_ALLOWED_HOSTS
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would rename the argument to avoid this

allowedOrigins?.let { this.allowedOrigins = it }
}
}

val transportManager = TransportManager<SseServerTransport>()

sse {
Expand All @@ -86,20 +111,32 @@ public fun Route.mcp(block: ServerSSESession.() -> Server) {
* over [Server-Sent Events (SSE) Transport](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse)
* and sets up routing with the provided configuration block.
*
* @param enableDnsRebindingProtection whether to install [DnsRebindingProtection] on this route. Defaults to `true`.
* @param allowedHosts hostnames allowed in the `Host` header. Defaults to [LOCALHOST_ALLOWED_HOSTS].
* @param allowedOrigins origins allowed in the `Origin` header, or `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,
) {
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,
) {
Expand All @@ -109,6 +146,13 @@ private fun Application.mcpStreamableHttp(

routing {
route(path) {
if (enableDnsRebindingProtection) {
install(DnsRebindingProtection) {
this.allowedHosts = allowedHosts ?: LOCALHOST_ALLOWED_HOSTS
allowedOrigins?.let { this.allowedOrigins = it }
}
}

sse {
val transport = existingStreamableTransport(call, transportManager) ?: return@sse
transport.handleRequest(this, call)
Expand Down Expand Up @@ -140,10 +184,11 @@ private fun Application.mcpStreamableHttp(
* Simple request/response pairs are returned as JSON (not SSE streams).
*
* @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 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_ALLOWED_HOSTS].
* @param allowedOrigins A list of origins allowed to perform cross-origin requests (CORS).
* If `null`, no restrictions are applied.
* 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)
Expand All @@ -153,34 +198,45 @@ private fun Application.mcpStreamableHttp(
@Suppress("LongParameterList")
public fun Application.mcpStreamableHttp(
path: String = "/mcp",
enableDnsRebindingProtection: Boolean = false,
enableDnsRebindingProtection: Boolean = true,
allowedHosts: List<String>? = null,
allowedOrigins: List<String>? = null,
eventStore: EventStore? = null,
block: RoutingContext.() -> Server,
) {
mcpStreamableHttp(
path = path,
enableDnsRebindingProtection = enableDnsRebindingProtection,
allowedHosts = allowedHosts,
allowedOrigins = allowedOrigins,
configuration = StreamableHttpServerTransport.Configuration(
enableDnsRebindingProtection = enableDnsRebindingProtection,
allowedHosts = allowedHosts,
allowedOrigins = allowedOrigins,
eventStore = eventStore,
enableJsonResponse = true,
),
block = block,
)
}

@Suppress("LongParameterList")
private fun Application.mcpStatelessStreamableHttp(
path: String = "/mcp",
enableDnsRebindingProtection: Boolean,
allowedHosts: List<String>?,
allowedOrigins: List<String>?,
configuration: StreamableHttpServerTransport.Configuration,
block: RoutingContext.() -> Server,
) {
install(SSE)

routing {
route(path) {
if (enableDnsRebindingProtection) {
install(DnsRebindingProtection) {
this.allowedHosts = allowedHosts ?: LOCALHOST_ALLOWED_HOSTS
allowedOrigins?.let { this.allowedOrigins = it }
}
}

post {
mcpStatelessStreamableHttpEndpoint(
configuration = configuration,
Expand Down Expand Up @@ -213,9 +269,10 @@ private fun Application.mcpStatelessStreamableHttp(
* Simple request/response pairs are returned as JSON (not SSE streams).
*
* @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_ALLOWED_HOSTS].
* @param allowedOrigins A list of allowed origins for CORS. 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.
Expand All @@ -224,18 +281,18 @@ private fun Application.mcpStatelessStreamableHttp(
@Suppress("LongParameterList")
public fun Application.mcpStatelessStreamableHttp(
path: String = "/mcp",
enableDnsRebindingProtection: Boolean = false,
enableDnsRebindingProtection: Boolean = true,
allowedHosts: List<String>? = null,
allowedOrigins: List<String>? = null,
eventStore: EventStore? = null,
block: RoutingContext.() -> Server,
) {
mcpStatelessStreamableHttp(
path = path,
enableDnsRebindingProtection = enableDnsRebindingProtection,
allowedHosts = allowedHosts,
allowedOrigins = allowedOrigins,
configuration = StreamableHttpServerTransport.Configuration(
enableDnsRebindingProtection = enableDnsRebindingProtection,
allowedHosts = allowedHosts,
allowedOrigins = allowedOrigins,
eventStore = eventStore,
enableJsonResponse = true,
),
Expand Down
Loading
Loading