Merge commit from fork

This commit is contained in:
Pavel Gorgulov 2026-06-02 12:31:28 +02:00 • committed by GitHub
commit 6e6f80512f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 80 additions and 2 deletions

View file

@ -125,6 +125,12 @@ public final class io/modelcontextprotocol/kotlin/sdk/shared/RequestOptions : io
public fun toString ()Ljava/lang/String;
}
public final class io/modelcontextprotocol/kotlin/sdk/shared/TooLongFrameException : java/io/IOException {
public fun <init> (JI)V
public final fun getFrameSize ()J
public final fun getMaxFrameSize ()I
}
public abstract interface class io/modelcontextprotocol/kotlin/sdk/shared/Transport {
public abstract fun close (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun onClose (Lkotlin/jvm/functions/Function0;)V

View file

@ -4,13 +4,24 @@ import io.github.oshai.kotlinlogging.KotlinLogging
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.types.McpJson
import kotlinx.io.Buffer
import kotlinx.io.IOException
import kotlinx.io.indexOf
import kotlinx.io.readString
/**
* Buffers a continuous stdio stream into discrete JSON-RPC messages.
*
* A single newline-terminated frame may not exceed an internal size cap (16 MiB): a peer that streams
* bytes without ever sending a newline triggers a [TooLongFrameException] from [readMessage] instead
* of growing the buffer without bound. A non-positive cap disables the check.
*/
public class ReadBuffer {
public class ReadBuffer internal constructor(private val maxFrameSize: Int) {
public constructor() : this(DEFAULT_MAX_FRAME_SIZE)
internal companion object {
internal const val DEFAULT_MAX_FRAME_SIZE: Int = 16 * 1024 * 1024
}
private val logger = KotlinLogging.logger { }
@ -53,7 +64,11 @@ public class ReadBuffer {
private fun readNextLine(): String? {
val lfIndex = if (buffer.exhausted()) -1L else buffer.indexOf('\n'.code.toByte())
if (lfIndex == -1L) return null
if (lfIndex == -1L) {
failIfFrameTooLong(buffer.size)
return null
}
failIfFrameTooLong(lfIndex)
return if (lfIndex == 0L) {
buffer.skip(1)
@ -71,6 +86,13 @@ public class ReadBuffer {
}
}
private fun failIfFrameTooLong(frameSize: Long) {
if (maxFrameSize in 1..<frameSize) {
buffer.clear()
throw TooLongFrameException(frameSize, maxFrameSize)
}
}
private fun tryRecover(line: String): JSONRPCMessage? {
// if there is a non-JSON object prefix, try to parse from the first '{' onward.
val braceIndex = line.indexOf('{')
@ -92,6 +114,20 @@ public class ReadBuffer {
}
}
/**
* Thrown by [ReadBuffer.readMessage] when a single newline-terminated frame exceeds the maximum size
* before its terminator arrives — i.e. a peer is streaming data without ever framing it. Transports
* treat it as fatal and close the connection.
*
* @property frameSize number of unframed bytes observed when the limit was tripped
* @property maxFrameSize the maximum frame size that was exceeded
*/
public class TooLongFrameException(public val frameSize: Long, public val maxFrameSize: Int) :
IOException(
"JSON-RPC frame exceeded the maximum size of $maxFrameSize bytes " +
"before a newline terminator (observed $frameSize bytes).",
)
internal fun deserializeMessage(line: String): JSONRPCMessage = McpJson.decodeFromString<JSONRPCMessage>(line)
/** Serializes a [JSONRPCMessage] to its JSON string representation with a trailing newline. */

View file

@ -6,7 +6,9 @@ import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCNotification
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
class ReadBufferTest {
@ -75,4 +77,35 @@ class ReadBufferTest {
val message = readBuffer.readMessage()
assertEquals(testMessage, message)
}
@Test
fun `should fail when an unframed blob exceeds the cap`() {
val readBuffer = ReadBuffer(maxFrameSize = 64)
// No newline ever arrives: the memory-exhaustion vector.
readBuffer.append(ByteArray(100) { 'a'.code.toByte() })
val ex = assertFailsWith<TooLongFrameException> { readBuffer.readMessage() }
assertContains(ex.message.orEmpty(), "maximum size")
}
@Test
fun `should fail when a completed line exceeds the cap`() {
val readBuffer = ReadBuffer(maxFrameSize = 64)
readBuffer.append(ByteArray(100) { 'a'.code.toByte() } + '\n'.code.toByte())
assertFailsWith<TooLongFrameException> { readBuffer.readMessage() }
}
@Test
fun `should not enforce a cap when maxFrameSize is non-positive`() {
val readBuffer = ReadBuffer(maxFrameSize = 0)
// Well beyond any small cap and still no newline — must not throw when disabled.
readBuffer.append(ByteArray(8192) { 'a'.code.toByte() })
assertNull(readBuffer.readMessage())
}
@Test
fun `should parse a message that fits under the cap`() {
val readBuffer = ReadBuffer(maxFrameSize = 1024)
readBuffer.append(serializeMessage(testMessage).encodeToByteArray())
assertEquals(testMessage, readBuffer.readMessage())
}
}