Skip to content
Merged
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
5 changes: 3 additions & 2 deletions kotlin-sdk-server/api/kotlin-sdk-server.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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;Lkotlin/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;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;
}

Expand Down
1 change: 1 addition & 0 deletions kotlin-sdk-server/detekt-baseline-main.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<CurrentIssues>
<ID>InjectDispatcher:FeatureNotificationService.kt:FeatureNotificationService$Default</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>MaxLineLength:SSEServerTransport.kt:SseServerTransport$"SSEServerTransport already started! If using Server class, note that connect() calls start() automatically."</ID>
<ID>MaxLineLength:SSEServerTransport.kt:SseServerTransport$*</ID>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"

/**
Expand Down Expand Up @@ -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,
Expand All @@ -149,7 +152,14 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
public val allowedOrigins: List<String>? = 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
Expand Down Expand Up @@ -661,24 +671,25 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
}
}

@Suppress("ReturnCount", "MagicNumber")
@Suppress("ReturnCount")
private suspend fun parseBody(call: ApplicationCall): List<JSONRPCMessage>? {
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()
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.

nit: If there is a content length header and it's bigger than max body size, then receiving text is a waste of time and memory. Request can be immediately rejected.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Already covered — Content-Length is checked before receiveText() (line 676). The body-length check after receiveText() is a fallback for missing or inaccurate Content-Length headers.

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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -63,6 +65,15 @@ class StreamableHttpServerTransportTest {
null,
"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"
Expand Down Expand Up @@ -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<IllegalArgumentException> {
StreamableHttpServerTransport.Configuration(maxRequestBodySize = -1)
}
}

private fun ApplicationTestBuilder.configureTransportEndpoint(transport: StreamableHttpServerTransport) {
application {
routing {
Expand Down
Loading