diff --git a/kotlin-sdk-server/api/kotlin-sdk-server.api b/kotlin-sdk-server/api/kotlin-sdk-server.api index 3acaa648..6094b7ca 100644 --- a/kotlin-sdk-server/api/kotlin-sdk-server.api +++ b/kotlin-sdk-server/api/kotlin-sdk-server.api @@ -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 @@ -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 (Ljava/lang/String;Lio/ktor/server/sse/ServerSSESession;)V + public fun (Ljava/lang/String;Lio/ktor/server/sse/ServerSSESession;J)V + public synthetic fun (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; diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServer.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServer.kt index d6191dff..d50c4836 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServer.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/KtorServer.kt @@ -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. @@ -53,10 +55,11 @@ public fun Route.mcp( enableDnsRebindingProtection: Boolean = true, allowedHosts: List? = null, allowedOrigins: List? = null, + maxRequestBodySize: Long = DEFAULT_MAX_REQUEST_BODY_SIZE, block: ServerSSESession.() -> Server, ) { route(path) { - mcp(enableDnsRebindingProtection, allowedHosts, allowedOrigins, block) + mcp(enableDnsRebindingProtection, allowedHosts, allowedOrigins, maxRequestBodySize, block) } } @@ -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? = null, allowedOrigins: List? = null, + maxRequestBodySize: Long = DEFAULT_MAX_REQUEST_BODY_SIZE, block: ServerSSESession.() -> Server, ) { try { @@ -99,7 +106,7 @@ public fun Route.mcp( val transportManager = TransportManager() sse { - mcpSseEndpoint("", transportManager, block) + mcpSseEndpoint("", transportManager, maxRequestBodySize, block) } post { @@ -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? = null, allowedOrigins: List? = 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) } } @@ -319,9 +330,10 @@ public fun Application.mcpStatelessStreamableHttp( private suspend fun ServerSSESession.mcpSseEndpoint( postEndpoint: String, transportManager: TransportManager, + maxRequestBodySize: Long, block: ServerSSESession.() -> Server, ) { - val transport = mcpSseTransport(postEndpoint, transportManager) + val transport = mcpSseTransport(postEndpoint, transportManager, maxRequestBodySize) val server = block() @@ -340,8 +352,9 @@ private suspend fun ServerSSESession.mcpSseEndpoint( private fun ServerSSESession.mcpSseTransport( postEndpoint: String, transportManager: TransportManager, + 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}" } diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/RequestBody.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/RequestBody.kt new file mode 100644 index 00000000..cc5c0f61 --- /dev/null +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/RequestBody.kt @@ -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() +} diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SSEServerTransport.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SSEServerTransport.kt index a6d46183..f525ec29 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SSEServerTransport.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SSEServerTransport.kt @@ -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 @@ -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. */ @@ -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 { @@ -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) diff --git a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport.kt b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport.kt index ae8a39d1..c2133500 100644 --- a/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport.kt +++ b/kotlin-sdk-server/src/commonMain/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport.kt @@ -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 @@ -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" /** @@ -703,23 +701,13 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } private suspend fun parseBody(call: ApplicationCall): List? { - 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 } diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/RequestBodyTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/RequestBodyTest.kt new file mode 100644 index 00000000..447e7da5 --- /dev/null +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/RequestBodyTest.kt @@ -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 + } +} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SseServerTransportTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SseServerTransportTest.kt new file mode 100644 index 00000000..a0756f34 --- /dev/null +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/SseServerTransportTest.kt @@ -0,0 +1,90 @@ +package io.modelcontextprotocol.kotlin.sdk.server + +import io.kotest.matchers.shouldBe +import io.ktor.client.request.post +import io.ktor.client.request.prepareGet +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsChannel +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +import io.ktor.server.application.install +import io.ktor.server.routing.post +import io.ktor.server.routing.routing +import io.ktor.server.sse.SSE +import io.ktor.server.sse.ServerSSESession +import io.ktor.server.testing.testApplication +import io.ktor.utils.io.readLine +import io.mockk.mockk +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.test.Test + +class SseServerTransportTest : AbstractKtorExtensionsTest() { + + @Test + fun `handlePostMessage on a not-started transport does not deliver the message`() = testApplication { + // A registered onMessage callback opens the delivery gate, so if the not-initialized branch + // falls through it would wrongly hand the message to the application. It must return instead. + val transport = SseServerTransport("/messages", mockk(relaxed = true)) + val delivered = AtomicBoolean(false) + transport.onMessage { delivered.set(true) } + + application { + routing { + post("/messages") { transport.handlePostMessage(call) } + } + } + + val response = client.post("/messages") { + contentType(ContentType.Application.Json) + setBody("""{"jsonrpc":"2.0","id":1,"method":"ping"}""") + } + + response.status shouldBe HttpStatusCode.InternalServerError + delivered.get() shouldBe false + } + + @Test + fun `SSE POST exceeding maxRequestBodySize is rejected with 413`() = testApplication { + application { + install(SSE) + routing { + mcp(enableDnsRebindingProtection = false, maxRequestBodySize = MAX_BODY) { testServer() } + } + } + + client.prepareGet("/").execute { response -> + val sessionId = response.readSessionId() + requireNotNull(sessionId) { "sessionId not found in SSE endpoint event" } + + // A body one byte over the configured limit must be rejected before processing. + val oversized = "x".repeat((MAX_BODY + 1).toInt()) + val postResponse = client.post("/?sessionId=$sessionId") { + contentType(ContentType.Application.Json) + setBody(oversized) + } + postResponse.status shouldBe HttpStatusCode.PayloadTooLarge + } + } + + private suspend fun HttpResponse.readSessionId(): String? { + val channel = bodyAsChannel() + var eventName: String? = null + while (!channel.isClosedForRead) { + val line = channel.readLine() ?: break + when { + line.startsWith("event:") -> eventName = line.substringAfter("event:").trim() + + line.startsWith("data:") && eventName == "endpoint" -> { + return line.substringAfter("data:").trim().substringAfter("sessionId=").ifEmpty { null } + } + } + } + return null + } + + private companion object { + const val MAX_BODY = 1024L + } +} diff --git a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransportTest.kt b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransportTest.kt index f6dda152..d7dec4e3 100644 --- a/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransportTest.kt +++ b/kotlin-sdk-server/src/jvmTest/kotlin/io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransportTest.kt @@ -24,6 +24,8 @@ 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 io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.readLine import io.ktor.utils.io.readUTF8Line import io.modelcontextprotocol.kotlin.sdk.types.ClientCapabilities import io.modelcontextprotocol.kotlin.sdk.types.EmptyResult @@ -421,6 +423,34 @@ class StreamableHttpServerTransportTest { response.status shouldBe HttpStatusCode.PayloadTooLarge } + @Test + fun `POST with oversized chunked body and no Content-Length returns 413`() = testApplication { + configTestServer() + + val client = createTestClient() + + val transport = StreamableHttpServerTransport( + StreamableHttpServerTransport.Configuration(enableJsonResponse = true, maxRequestBodySize = 1024), + ) + transport.onMessage { message -> + if (message is JSONRPCRequest) { + transport.send(JSONRPCResponse(message.id, EmptyResult())) + } + } + + configureTransportEndpoint(transport) + + // Stream the body as a channel so the client uses chunked transfer-encoding with no + // Content-Length header: the size limit must hold without trusting the (absent) header. + val oversized = ByteReadChannel("x".repeat(4096).encodeToByteArray()) + val response = client.post(path) { + addStreamableHeaders() + setBody(oversized) + } + + response.status shouldBe HttpStatusCode.PayloadTooLarge + } + @ParameterizedTest @MethodSource("maxBodySizeTestCases") fun `POST with custom max request body size validates payload size`( @@ -507,7 +537,7 @@ class StreamableHttpServerTransportTest { // New stream is alive val secondChannel = secondResponse.bodyAsChannel() - val firstLine = secondChannel.readUTF8Line() + val firstLine = secondChannel.readLine() firstLine.shouldNotBeNull() secondChannel.isClosedForRead shouldBe false } @@ -546,7 +576,7 @@ class StreamableHttpServerTransportTest { header("mcp-protocol-version", LATEST_PROTOCOL_VERSION) }.execute { response -> response.status shouldBe HttpStatusCode.OK - response.bodyAsChannel().readUTF8Line() + response.bodyAsChannel().readLine() } // Step 3: Immediately reconnect — the transport should close the stale @@ -561,7 +591,7 @@ class StreamableHttpServerTransportTest { response.headers[MCP_SESSION_ID_HEADER] shouldBe sessionId val channel = response.bodyAsChannel() - val firstLine = channel.readUTF8Line() + val firstLine = channel.readLine() firstLine.shouldNotBeNull() channel.isClosedForRead shouldBe false } @@ -604,7 +634,7 @@ class StreamableHttpServerTransportTest { // Verify the stream is alive by reading at least one line (flush event) val channel = response.bodyAsChannel() - val firstLine = channel.readUTF8Line() + val firstLine = channel.readLine() firstLine.shouldNotBeNull() channel.isClosedForRead shouldBe false }