From 43e6bda98771bd8c90c7889a248d8e8ba98f40ba Mon Sep 17 00:00:00 2001 From: jiwon Date: Thu, 26 Mar 2026 05:52:52 +0900 Subject: [PATCH] feat(server): support configurable max request payload size (#521) --- kotlin-sdk-server/api/kotlin-sdk-server.api | 5 +- kotlin-sdk-server/detekt-baseline-main.xml | 1 + .../server/StreamableHttpServerTransport.kt | 27 +++++++--- .../StreamableHttpServerTransportTest.kt | 50 +++++++++++++++++++ 4 files changed, 73 insertions(+), 10 deletions(-) diff --git a/kotlin-sdk-server/api/kotlin-sdk-server.api b/kotlin-sdk-server/api/kotlin-sdk-server.api index 027af1c76..70e8e1401 100644 --- a/kotlin-sdk-server/api/kotlin-sdk-server.api +++ b/kotlin-sdk-server/api/kotlin-sdk-server.api @@ -227,13 +227,14 @@ public final class io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServe } public final class io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport$Configuration { - public synthetic fun (ZZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/time/Duration;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (ZZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/time/Duration;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (ZZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/time/Duration;JILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (ZZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/time/Duration;JLkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getAllowedHosts ()Ljava/util/List; public final fun getAllowedOrigins ()Ljava/util/List; public final fun getEnableDnsRebindingProtection ()Z public final fun getEnableJsonResponse ()Z public final fun getEventStore ()Lio/modelcontextprotocol/kotlin/sdk/server/EventStore; + public final fun getMaxRequestBodySize ()J public final fun getRetryInterval-FghU774 ()Lkotlin/time/Duration; } diff --git a/kotlin-sdk-server/detekt-baseline-main.xml b/kotlin-sdk-server/detekt-baseline-main.xml index 141c7d086..a27f38a22 100644 --- a/kotlin-sdk-server/detekt-baseline-main.xml +++ b/kotlin-sdk-server/detekt-baseline-main.xml @@ -4,6 +4,7 @@ InjectDispatcher:FeatureNotificationService.kt:FeatureNotificationService$Default LongParameterList:KtorServer.kt:private suspend fun RoutingContext.streamableTransport: StreamableHttpServerTransport? + LongParameterList:StreamableHttpServerTransport.kt:StreamableHttpServerTransport.Configuration MagicNumber:StdioServerTransport.kt:StdioServerTransport$8192 MaxLineLength:SSEServerTransport.kt:SseServerTransport$"SSEServerTransport already started! If using Server class, note that connect() calls start() automatically." MaxLineLength:SSEServerTransport.kt:SseServerTransport$* 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 821c525a1..1ea8d83d0 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 @@ -45,7 +45,7 @@ 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 MAXIMUM_MESSAGE_SIZE = 4 * 1024 * 1024 // 4 MB +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" /** @@ -141,6 +141,9 @@ public class StreamableHttpServerTransport(private val configuration: Configurat * * @property retryInterval Retry interval for event handling or reconnection attempts. * Defaults to `null`. + * + * @property maxRequestBodySize Maximum allowed size (in bytes) for incoming request bodies. + * Defaults to 4 MB (4,194,304 bytes). */ public class Configuration( public val enableJsonResponse: Boolean = false, @@ -149,7 +152,14 @@ public class StreamableHttpServerTransport(private val configuration: Configurat public val allowedOrigins: List? = null, public val eventStore: EventStore? = null, public val retryInterval: Duration? = null, - ) + public val maxRequestBodySize: Long = DEFAULT_MAX_REQUEST_BODY_SIZE, + ) { + init { + require(maxRequestBodySize > 0) { + "maxRequestBodySize must be greater than 0" + } + } + } public var sessionId: String? = null private set @@ -661,24 +671,25 @@ public class StreamableHttpServerTransport(private val configuration: Configurat } } - @Suppress("ReturnCount", "MagicNumber") + @Suppress("ReturnCount") private suspend fun parseBody(call: ApplicationCall): List? { - val contentLength = call.request.header(HttpHeaders.ContentLength)?.toIntOrNull() ?: 0 - if (contentLength > MAXIMUM_MESSAGE_SIZE) { + val maxSize = configuration.maxRequestBodySize + val contentLength = call.request.header(HttpHeaders.ContentLength)?.toLongOrNull() ?: 0L + if (contentLength > maxSize) { call.reject( HttpStatusCode.PayloadTooLarge, RPCError.ErrorCode.INVALID_REQUEST, - "Invalid Request: message size exceeds maximum of ${MAXIMUM_MESSAGE_SIZE / (1024 * 1024)} MB", + "Invalid Request: message size exceeds maximum of $maxSize bytes", ) return null } val body = call.receiveText() - if (body.length > MAXIMUM_MESSAGE_SIZE) { + if (body.length.toLong() > maxSize) { call.reject( HttpStatusCode.PayloadTooLarge, RPCError.ErrorCode.INVALID_REQUEST, - "Invalid Request: message size exceeds maximum of ${MAXIMUM_MESSAGE_SIZE / (1024 * 1024)} MB", + "Invalid Request: message size exceeds maximum of $maxSize bytes", ) return null } 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 c6e539adb..9cf916afb 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 @@ -43,10 +43,12 @@ import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import java.util.concurrent.atomic.AtomicBoolean import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation @@ -63,6 +65,15 @@ class StreamableHttpServerTransportTest { null, "lolol", ) + + private val sizeTestPayload = "x".repeat(64) + + @JvmStatic + fun maxBodySizeTestCases(): List = listOf( + Arguments.of(sizeTestPayload.length.toLong() - 1, HttpStatusCode.PayloadTooLarge), + Arguments.of(sizeTestPayload.length.toLong(), HttpStatusCode.BadRequest), + Arguments.of(sizeTestPayload.length.toLong() + 1, HttpStatusCode.BadRequest), + ) } private val path = "/transport" @@ -383,6 +394,45 @@ class StreamableHttpServerTransportTest { response.status shouldBe HttpStatusCode.PayloadTooLarge } + @ParameterizedTest + @MethodSource("maxBodySizeTestCases") + fun `POST with custom max request body size validates payload size`( + maxSize: Long, + expectedStatus: HttpStatusCode, + ) = testApplication { + configTestServer() + + val client = createTestClient() + + val transport = StreamableHttpServerTransport( + StreamableHttpServerTransport.Configuration( + enableJsonResponse = true, + maxRequestBodySize = maxSize, + ), + ) + transport.onMessage { message -> + if (message is JSONRPCRequest) { + transport.send(JSONRPCResponse(message.id, EmptyResult())) + } + } + + configureTransportEndpoint(transport) + + val response = client.post(path) { + addStreamableHeaders() + setBody(sizeTestPayload) + } + + response.status shouldBe expectedStatus + } + + @Test + fun `Configuration with negative maxRequestBodySize throws IllegalArgumentException`() { + assertFailsWith { + StreamableHttpServerTransport.Configuration(maxRequestBodySize = -1) + } + } + private fun ApplicationTestBuilder.configureTransportEndpoint(transport: StreamableHttpServerTransport) { application { routing {