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
15 changes: 8 additions & 7 deletions kotlin-sdk-server/api/kotlin-sdk-server.api
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,12 @@ public final class io/modelcontextprotocol/kotlin/sdk/server/HostValidationKt {
}

public final class io/modelcontextprotocol/kotlin/sdk/server/KtorServerKt {
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 mcp (Lio/ktor/server/application/Application;ZLjava/util/List;Ljava/util/List;JLkotlin/jvm/functions/Function1;)V
public static final fun mcp (Lio/ktor/server/routing/Route;Ljava/lang/String;ZLjava/util/List;Ljava/util/List;JLkotlin/jvm/functions/Function1;)V
public static final fun mcp (Lio/ktor/server/routing/Route;ZLjava/util/List;Ljava/util/List;JLkotlin/jvm/functions/Function1;)V
public static synthetic fun mcp$default (Lio/ktor/server/application/Application;ZLjava/util/List;Ljava/util/List;JLkotlin/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;JLkotlin/jvm/functions/Function1;ILjava/lang/Object;)V
public static synthetic fun mcp$default (Lio/ktor/server/routing/Route;ZLjava/util/List;Ljava/util/List;JLkotlin/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 Expand Up @@ -208,7 +208,8 @@ public class io/modelcontextprotocol/kotlin/sdk/server/ServerSession : io/modelc
}

public final class io/modelcontextprotocol/kotlin/sdk/server/SseServerTransport : io/modelcontextprotocol/kotlin/sdk/shared/AbstractTransport {
public fun <init> (Ljava/lang/String;Lio/ktor/server/sse/ServerSSESession;)V
public fun <init> (Ljava/lang/String;Lio/ktor/server/sse/ServerSSESession;J)V
public synthetic fun <init> (Ljava/lang/String;Lio/ktor/server/sse/ServerSSESession;JILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public final fun getSessionId ()Ljava/lang/String;
public final fun handleMessage (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ private val logger = KotlinLogging.logger {}
* @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 maxRequestBodySize maximum allowed size, in bytes, of an incoming POST body; larger requests are
* rejected with `413 Payload Too Large`. Defaults to 4 MiB.
* @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.
Expand All @@ -53,10 +55,11 @@ public fun Route.mcp(
enableDnsRebindingProtection: Boolean = true,
allowedHosts: List<String>? = null,
allowedOrigins: List<String>? = null,
maxRequestBodySize: Long = DEFAULT_MAX_REQUEST_BODY_SIZE,
block: ServerSSESession.() -> Server,
) {
route(path) {
mcp(enableDnsRebindingProtection, allowedHosts, allowedOrigins, block)
mcp(enableDnsRebindingProtection, allowedHosts, allowedOrigins, maxRequestBodySize, block)
}
}

Expand All @@ -72,15 +75,19 @@ public fun Route.mcp(
* @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 maxRequestBodySize maximum allowed size, in bytes, of an incoming POST body; larger requests are
* rejected with `413 Payload Too Large`. Defaults to 4 MiB.
* @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
@Suppress("LongParameterList")
public fun Route.mcp(
enableDnsRebindingProtection: Boolean = true,
allowedHosts: List<String>? = null,
allowedOrigins: List<String>? = null,
maxRequestBodySize: Long = DEFAULT_MAX_REQUEST_BODY_SIZE,
block: ServerSSESession.() -> Server,
) {
try {
Expand All @@ -99,7 +106,7 @@ public fun Route.mcp(
val transportManager = TransportManager<SseServerTransport>()

sse {
mcpSseEndpoint("", transportManager, block)
mcpSseEndpoint("", transportManager, maxRequestBodySize, block)
}

post {
Expand All @@ -120,21 +127,25 @@ public fun Route.mcp(
* @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 maxRequestBodySize maximum allowed size, in bytes, of an incoming POST body; larger requests are
* rejected with `413 Payload Too Large`. Defaults to 4 MiB.
* @param block factory block with access to the [ServerSSESession]
* that creates and returns the [Server] to handle the connection.
*/
@KtorDsl
@Suppress("LongParameterList")
public fun Application.mcp(
enableDnsRebindingProtection: Boolean = true,
allowedHosts: List<String>? = null,
allowedOrigins: List<String>? = null,
maxRequestBodySize: Long = DEFAULT_MAX_REQUEST_BODY_SIZE,
block: ServerSSESession.() -> Server,
) {
installMcpContentNegotiation()
install(SSE)

routing {
mcp(enableDnsRebindingProtection, allowedHosts, allowedOrigins, block)
mcp(enableDnsRebindingProtection, allowedHosts, allowedOrigins, maxRequestBodySize, block)
}
}

Expand Down Expand Up @@ -319,9 +330,10 @@ public fun Application.mcpStatelessStreamableHttp(
private suspend fun ServerSSESession.mcpSseEndpoint(
postEndpoint: String,
transportManager: TransportManager<SseServerTransport>,
maxRequestBodySize: Long,
block: ServerSSESession.() -> Server,
) {
val transport = mcpSseTransport(postEndpoint, transportManager)
val transport = mcpSseTransport(postEndpoint, transportManager, maxRequestBodySize)

val server = block()

Expand All @@ -340,8 +352,9 @@ private suspend fun ServerSSESession.mcpSseEndpoint(
private fun ServerSSESession.mcpSseTransport(
postEndpoint: String,
transportManager: TransportManager<SseServerTransport>,
maxRequestBodySize: Long,
): SseServerTransport {
val transport = SseServerTransport(postEndpoint, this)
val transport = SseServerTransport(postEndpoint, this, maxRequestBodySize)
transportManager.addTransport(transport.sessionId, transport)
logger.info { "New SSE connection established and stored with sessionId: ${transport.sessionId}" }

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package io.modelcontextprotocol.kotlin.sdk.server

import io.ktor.server.application.ApplicationCall
import io.ktor.server.request.receiveChannel
import io.ktor.utils.io.readRemaining
import kotlinx.io.Buffer
import kotlinx.io.IOException
import kotlinx.io.readString

/** Default maximum size (in bytes) for an incoming HTTP request body: 4 MiB. */
internal const val DEFAULT_MAX_REQUEST_BODY_SIZE: Long = 4L * 1024 * 1024

/** Signals that an incoming request body exceeded the maximum size a transport is willing to read. */
internal class RequestBodyTooLargeException(val maxBodySize: Long) :
IOException("Request body exceeds the maximum of $maxBodySize bytes.")

/**
* Reads the request body as a UTF-8 string, rejecting bodies larger than [maxBytes].
*
* At most `maxBytes + 1` bytes are ever pulled from the request channel, so an oversized or unframed
* body cannot exhaust memory regardless of the (untrusted) `Content-Length` header.
*
* @throws RequestBodyTooLargeException if the body exceeds [maxBytes]
*/
internal suspend fun ApplicationCall.receiveTextWithLimit(maxBytes: Long): String {
val buffer = Buffer()
val read = receiveChannel().readRemaining(maxBytes + 1).transferTo(buffer)
if (read > maxBytes) {
buffer.clear()
throw RequestBodyTooLargeException(maxBytes)
}
return buffer.readString()
}
Comment on lines +25 to +33
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import io.ktor.http.HttpStatusCode
import io.ktor.http.encodeURLPath
import io.ktor.server.application.ApplicationCall
import io.ktor.server.request.contentType
import io.ktor.server.request.receiveText
import io.ktor.server.response.respondText
import io.ktor.server.sse.ServerSSESession
import io.modelcontextprotocol.kotlin.sdk.shared.AbstractTransport
Expand All @@ -29,10 +28,19 @@ internal const val SESSION_ID_PARAM = "sessionId"
*
* @param endpoint relative or absolute URL the client will POST messages to
* @param session active SSE session used to deliver server-to-client events
* @param maxRequestBodySize maximum allowed size, in bytes, of an incoming POST body; larger requests are
* rejected with `413 Payload Too Large` without being buffered in full. Defaults to 4 MiB.
*/
@OptIn(ExperimentalAtomicApi::class)
public class SseServerTransport(private val endpoint: String, private val session: ServerSSESession) :
AbstractTransport() {
public class SseServerTransport(
private val endpoint: String,
private val session: ServerSSESession,
private val maxRequestBodySize: Long = DEFAULT_MAX_REQUEST_BODY_SIZE,
) : AbstractTransport() {
init {
require(maxRequestBodySize > 0) { "maxRequestBodySize must be greater than 0" }
}

private val initialized: AtomicBoolean = AtomicBoolean(false)

/** Unique identifier for this transport session, generated randomly on creation. */
Expand Down Expand Up @@ -77,6 +85,7 @@ public class SseServerTransport(private val endpoint: String, private val sessio
val message = "SSE connection not established"
call.respondText(message, status = HttpStatusCode.InternalServerError)
_onError.invoke(IllegalStateException(message))
return
}

val body = try {
Expand All @@ -85,9 +94,13 @@ public class SseServerTransport(private val endpoint: String, private val sessio
error("Unsupported content-type: $ct")
}

call.receiveText()
call.receiveTextWithLimit(maxRequestBodySize)
} catch (e: CancellationException) {
throw e
} catch (e: RequestBodyTooLargeException) {
call.respondText(e.message ?: "Request body too large", status = HttpStatusCode.PayloadTooLarge)
_onError.invoke(e)
return
} catch (e: Exception) {
call.respondText("Invalid message: ${e.message}", status = HttpStatusCode.BadRequest)
_onError.invoke(e)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import io.ktor.server.application.ApplicationCall
import io.ktor.server.request.contentType
import io.ktor.server.request.header
import io.ktor.server.request.httpMethod
import io.ktor.server.request.receiveText
import io.ktor.server.response.header
import io.ktor.server.response.respond
import io.ktor.server.response.respondNullable
Expand Down Expand Up @@ -48,7 +47,6 @@ import kotlin.uuid.Uuid
internal const val MCP_SESSION_ID_HEADER = "mcp-session-id"
private const val MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version"
private const val MCP_RESUMPTION_TOKEN_HEADER = "Last-Event-ID"
private const val DEFAULT_MAX_REQUEST_BODY_SIZE: Long = 4L * 1024 * 1024 // 4 MB
private const val MIN_PRIMING_EVENT_PROTOCOL_VERSION = "2025-11-25"

/**
Expand Down Expand Up @@ -703,23 +701,13 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
}

private suspend fun parseBody(call: ApplicationCall): List<JSONRPCMessage>? {
val maxSize = configuration.maxRequestBodySize
val contentLength = call.request.header(HttpHeaders.ContentLength)?.toLongOrNull() ?: 0L
if (contentLength > maxSize) {
val body = try {
call.receiveTextWithLimit(configuration.maxRequestBodySize)
} catch (e: RequestBodyTooLargeException) {
call.reject(
HttpStatusCode.PayloadTooLarge,
RPCError.ErrorCode.INVALID_REQUEST,
"Invalid Request: message size exceeds maximum of $maxSize bytes",
)
return null
}

val body = call.receiveText()
if (body.length.toLong() > maxSize) {
call.reject(
HttpStatusCode.PayloadTooLarge,
RPCError.ErrorCode.INVALID_REQUEST,
"Invalid Request: message size exceeds maximum of $maxSize bytes",
"Invalid Request: message size exceeds maximum of ${e.maxBodySize} bytes",
)
return null
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package io.modelcontextprotocol.kotlin.sdk.server

import io.kotest.matchers.shouldBe
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.server.response.respondText
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import io.ktor.server.testing.ApplicationTestBuilder
import io.ktor.server.testing.testApplication
import kotlin.test.Test

class RequestBodyTest {

/** Wires a POST endpoint that echoes the body when it fits, or replies 413 when it is too large. */
private fun ApplicationTestBuilder.installEchoEndpoint(maxBytes: Long) {
install(io.ktor.server.sse.SSE)
application {
routing {
post("/echo") {
val text = try {
call.receiveTextWithLimit(maxBytes)
} catch (e: RequestBodyTooLargeException) {
call.respondText(e.message ?: "too large", status = HttpStatusCode.PayloadTooLarge)
return@post
}
call.respondText(text)
}
}
}
}

@Test
fun `body under the limit is returned intact`() = testApplication {
installEchoEndpoint(maxBytes = 1024)
val payload = "hello world"

val response = client.post("/echo") { setBody(payload) }

response.status shouldBe HttpStatusCode.OK
response.bodyAsText() shouldBe payload
}

@Test
fun `body exactly at the limit is accepted`() = testApplication {
val payload = "x".repeat(64)
installEchoEndpoint(maxBytes = payload.length.toLong())

val response = client.post("/echo") { setBody(payload) }

response.status shouldBe HttpStatusCode.OK
response.bodyAsText() shouldBe payload
}

@Test
fun `body exceeding the limit is rejected with 413`() = testApplication {
val payload = "x".repeat(65)
installEchoEndpoint(maxBytes = 64)

val response = client.post("/echo") { setBody(payload) }

response.status shouldBe HttpStatusCode.PayloadTooLarge
}

@Test
fun `large body exceeding the limit is rejected without buffering it whole`() = testApplication {
// 8 MB body against a 4 MB limit: must be rejected.
installEchoEndpoint(maxBytes = 4L * 1024 * 1024)
val payload = "x".repeat(8 * 1024 * 1024)

val response = client.post("/echo") { setBody(payload) }

response.status shouldBe HttpStatusCode.PayloadTooLarge
}

@Test
fun `multibyte UTF-8 body larger than a read chunk decodes intact`() = testApplication {
// Emoji is 4 bytes in UTF-8; a payload larger than the internal read chunk forces the
// accumulated bytes to span multiple chunks. Decoding once at the end must not corrupt
// characters that straddle a chunk boundary.
val unit = "a😀b" // mixes 1- and 4-byte code units so boundaries land mid-character
val payload = unit.repeat(40_000) // well over a 64 KiB read chunk
installEchoEndpoint(maxBytes = payload.encodeToByteArray().size.toLong())

val response = client.post("/echo") { setBody(payload) }

response.status shouldBe HttpStatusCode.OK
response.bodyAsText() shouldBe payload
}
}
Loading
Loading