feat(server): support configurable max request payload size (#646)

## Summary

Add `maxRequestBodySize` parameter to
`StreamableHttpServerTransport.Configuration`, allowing consumers to set
a custom request body size limit lower than the default 4 MB.

- Add `maxRequestBodySize: Long` property to `Configuration` with a
default of 4 MB (`4,194,304` bytes)
- Replace hardcoded `MAXIMUM_MESSAGE_SIZE` constant with the
configurable value in `parseBody()`
- Use `Long` for content-length comparison to avoid `Int` overflow on
large values

## Motivation and Context

Closes #521

The `StreamableHttpServerTransport` has a hardcoded 4 MB request body
limit. Consumers who need a lower limit currently have to manually count
the request payload size before passing it to the SDK. This change makes
the limit configurable via `Configuration`.

## How Has This Been Tested?

- Existing unit tests continue to pass (default 4 MB behavior unchanged)
- Added `POST with custom max request body size rejects oversized
payload` — verifies `PayloadTooLarge` is returned for payloads exceeding
the custom limit
- Added `POST at exactly custom max request body size is not rejected as
payload too large` — verifies payloads at exactly the limit are not
rejected by the size check

```bash
./gradlew :kotlin-sdk-server:jvmTest --tests "*.StreamableHttpServerTransportTest"
```

## Breaking Changes

None. The new parameter has a default value matching the previous
hardcoded limit.

## Types of changes

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] 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:
johny 2026-04-01 19:57:17 +09:00 • committed by GitHub
commit 6ce9ff81e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 73 additions and 10 deletions

View file

@ -227,13 +227,14 @@ public final class io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServe
} }
public final class io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport$Configuration { public final class io/modelcontextprotocol/kotlin/sdk/server/StreamableHttpServerTransport$Configuration {
public synthetic fun <init> (ZZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/time/Duration;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun <init> (ZZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/time/Duration;JILkotlin/jvm/internal/DefaultConstructorMarker;)V
public synthetic fun <init> (ZZLjava/util/List;Ljava/util/List;Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;Lkotlin/time/Duration;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun <init> (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 getAllowedHosts ()Ljava/util/List;
public final fun getAllowedOrigins ()Ljava/util/List; public final fun getAllowedOrigins ()Ljava/util/List;
public final fun getEnableDnsRebindingProtection ()Z public final fun getEnableDnsRebindingProtection ()Z
public final fun getEnableJsonResponse ()Z public final fun getEnableJsonResponse ()Z
public final fun getEventStore ()Lio/modelcontextprotocol/kotlin/sdk/server/EventStore; public final fun getEventStore ()Lio/modelcontextprotocol/kotlin/sdk/server/EventStore;
public final fun getMaxRequestBodySize ()J
public final fun getRetryInterval-FghU774 ()Lkotlin/time/Duration; public final fun getRetryInterval-FghU774 ()Lkotlin/time/Duration;
} }

View file

@ -4,6 +4,7 @@
<CurrentIssues> <CurrentIssues>
<ID>InjectDispatcher:FeatureNotificationService.kt:FeatureNotificationService$Default</ID> <ID>InjectDispatcher:FeatureNotificationService.kt:FeatureNotificationService$Default</ID>
<ID>LongParameterList:KtorServer.kt:private suspend fun RoutingContext.streamableTransport: StreamableHttpServerTransport?</ID> <ID>LongParameterList:KtorServer.kt:private suspend fun RoutingContext.streamableTransport: StreamableHttpServerTransport?</ID>
<ID>LongParameterList:StreamableHttpServerTransport.kt:StreamableHttpServerTransport.Configuration</ID>
<ID>MagicNumber:StdioServerTransport.kt:StdioServerTransport$8192</ID> <ID>MagicNumber:StdioServerTransport.kt:StdioServerTransport$8192</ID>
<ID>MaxLineLength:SSEServerTransport.kt:SseServerTransport$"SSEServerTransport already started! If using Server class, note that connect() calls start() automatically."</ID> <ID>MaxLineLength:SSEServerTransport.kt:SseServerTransport$"SSEServerTransport already started! If using Server class, note that connect() calls start() automatically."</ID>
<ID>MaxLineLength:SSEServerTransport.kt:SseServerTransport$*</ID> <ID>MaxLineLength:SSEServerTransport.kt:SseServerTransport$*</ID>

View file

@ -45,7 +45,7 @@ import kotlin.uuid.Uuid
internal const val MCP_SESSION_ID_HEADER = "mcp-session-id" internal const val MCP_SESSION_ID_HEADER = "mcp-session-id"
private const val MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version" private const val MCP_PROTOCOL_VERSION_HEADER = "mcp-protocol-version"
private const val MCP_RESUMPTION_TOKEN_HEADER = "Last-Event-ID" 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" 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. * @property retryInterval Retry interval for event handling or reconnection attempts.
* Defaults to `null`. * 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 class Configuration(
public val enableJsonResponse: Boolean = false, public val enableJsonResponse: Boolean = false,
@ -149,7 +152,14 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
public val allowedOrigins: List<String>? = null, public val allowedOrigins: List<String>? = null,
public val eventStore: EventStore? = null, public val eventStore: EventStore? = null,
public val retryInterval: Duration? = 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 public var sessionId: String? = null
private set 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<JSONRPCMessage>? { private suspend fun parseBody(call: ApplicationCall): List<JSONRPCMessage>? {
val contentLength = call.request.header(HttpHeaders.ContentLength)?.toIntOrNull() ?: 0 val maxSize = configuration.maxRequestBodySize
if (contentLength > MAXIMUM_MESSAGE_SIZE) { val contentLength = call.request.header(HttpHeaders.ContentLength)?.toLongOrNull() ?: 0L
if (contentLength > maxSize) {
call.reject( call.reject(
HttpStatusCode.PayloadTooLarge, HttpStatusCode.PayloadTooLarge,
RPCError.ErrorCode.INVALID_REQUEST, 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 return null
} }
val body = call.receiveText() val body = call.receiveText()
if (body.length > MAXIMUM_MESSAGE_SIZE) { if (body.length.toLong() > maxSize) {
call.reject( call.reject(
HttpStatusCode.PayloadTooLarge, HttpStatusCode.PayloadTooLarge,
RPCError.ErrorCode.INVALID_REQUEST, 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 return null
} }

View file

@ -43,10 +43,12 @@ import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import org.junit.jupiter.params.ParameterizedTest 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.MethodSource
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse import kotlin.test.assertFalse
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation as ClientContentNegotiation
@ -63,6 +65,15 @@ class StreamableHttpServerTransportTest {
null, null,
"lolol", "lolol",
) )
private val sizeTestPayload = "x".repeat(64)
@JvmStatic
fun maxBodySizeTestCases(): List<Arguments> = 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" private val path = "/transport"
@ -383,6 +394,45 @@ class StreamableHttpServerTransportTest {
response.status shouldBe HttpStatusCode.PayloadTooLarge 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<IllegalArgumentException> {
StreamableHttpServerTransport.Configuration(maxRequestBodySize = -1)
}
}
private fun ApplicationTestBuilder.configureTransportEndpoint(transport: StreamableHttpServerTransport) { private fun ApplicationTestBuilder.configureTransportEndpoint(transport: StreamableHttpServerTransport) {
application { application {
routing { routing {