Merge commit from fork
This commit is contained in:
parent
0eb1822853
commit
6e6f80512f
3 changed files with 80 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue