fix: classify malformed request params as invalid params (#886)

## Summary

Classify typed request deserialization failures as JSON-RPC `Invalid
Params`
instead of `Internal error`.

Fixes #830.

## Changes

- translate `SerializationException` from typed request decoding to
`-32602`
- return a stable `Invalid params` message without serialization
internals
- cover malformed `initialize` requests through session and Streamable
HTTP paths

## Scope and risk

The catch is limited to request parameter deserialization after the
JSON-RPC
method has been resolved. Envelope parsing, unknown methods, handler
failures,
and successful requests keep their existing behavior.

## Verification

```bash
./gradlew :integration-test:jvmTest --tests 'io.modelcontextprotocol.kotlin.sdk.server.ServerSessionInitializeTest'
./gradlew :kotlin-sdk-server:jvmTest --tests 'io.modelcontextprotocol.kotlin.sdk.server.StreamableHttpServerTransportTest'
./gradlew :kotlin-sdk-core:jvmTest --tests 'io.modelcontextprotocol.kotlin.sdk.shared.ProtocolTest'
./gradlew ktlintCheck apiCheck
```

Co-authored-by: Pavel Gorgulov <devcrocod@gmail.com>
This commit is contained in:
King Star 2026-07-23 00:12:12 +08:00 • committed by GitHub
commit cab669b108
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 81 additions and 1 deletions

View file

@ -7,9 +7,11 @@ import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest
import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCError
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCMessage
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCRequest
import io.modelcontextprotocol.kotlin.sdk.types.JSONRPCResponse
import io.modelcontextprotocol.kotlin.sdk.types.LATEST_PROTOCOL_VERSION
import io.modelcontextprotocol.kotlin.sdk.types.RPCError
import io.modelcontextprotocol.kotlin.sdk.types.RequestId
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.toJSON
import kotlinx.coroutines.CompletableDeferred
@ -18,9 +20,13 @@ import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonObject
import org.junit.jupiter.api.Test
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
@ -41,6 +47,18 @@ class ServerSessionInitializeTest {
),
)
private fun createMalformedInitializeRequest(): JSONRPCRequest = JSONRPCRequest(
id = RequestId(1),
method = "initialize",
params = buildJsonObject {
putJsonObject("capabilities") {}
putJsonObject("clientInfo") {
put("name", "repro")
put("version", "0.1.0")
}
},
)
@Test
fun `should handle first initialize request successfully`() = runTest {
val session = createSession()
@ -67,6 +85,28 @@ class ServerSessionInitializeTest {
// Both initialize requests arrive before notifications/initialized, in the serial dispatch
// phase, so processing and response order stay deterministic.
@Test
fun `should classify malformed initialize params as invalid params`() = runTest {
val session = createSession()
val (clientTransport, serverTransport) = InMemoryTransport.createLinkedPair()
val responseDone = CompletableDeferred<JSONRPCError>()
clientTransport.onMessage { message ->
if (message is JSONRPCError) {
responseDone.complete(message)
}
}
session.connect(serverTransport)
clientTransport.send(createMalformedInitializeRequest())
val response = responseDone.await()
assertEquals(RPCError.ErrorCode.INVALID_PARAMS, response.error.code)
assertFalse(response.error.message.contains("kotlinx.serialization"))
assertNull(session.clientCapabilities)
assertNull(session.clientVersion)
}
@Test
fun `should reject duplicate initialize request`() = runTest {
val session = createSession()

View file

@ -48,6 +48,7 @@ import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.encodeToJsonElement
@ -941,7 +942,15 @@ public abstract class Protocol(@PublishedApi internal val options: ProtocolOptio
_requestHandlers.update { current ->
current.putting(method.value) { jSONRPCRequest, extraHandler ->
val request = jSONRPCRequest.fromJSON()
val request = try {
jSONRPCRequest.fromJSON()
} catch (cause: SerializationException) {
throw McpException(
code = RPCError.ErrorCode.INVALID_PARAMS,
message = "Invalid params",
cause = cause,
)
}
val response = wrapped(request as T, extraHandler)
response
}

View file

@ -47,6 +47,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.ListResourcesResult
import io.modelcontextprotocol.kotlin.sdk.types.ListToolsResult
import io.modelcontextprotocol.kotlin.sdk.types.McpJson
import io.modelcontextprotocol.kotlin.sdk.types.Method
import io.modelcontextprotocol.kotlin.sdk.types.RPCError
import io.modelcontextprotocol.kotlin.sdk.types.RequestId
import io.modelcontextprotocol.kotlin.sdk.types.ServerCapabilities
import io.modelcontextprotocol.kotlin.sdk.types.Tool
@ -225,6 +226,36 @@ class StreamableHttpServerTransportTest {
initResponse.headers[MCP_SESSION_ID_HEADER] shouldBe null
}
@Test
fun `malformed initialize params return invalid params over streamable http`() = testApplication {
val mcpPath = "/mcp"
application {
mcpStreamableHttp(mcpPath, enableDnsRebindingProtection = false) {
Server(
Implementation("test-server", "1.0.0"),
ServerOptions(capabilities = ServerCapabilities()),
)
}
}
val client = createTestClient()
val response = client.post(mcpPath) {
addStreamableHeaders()
setBody(
"""
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{},"clientInfo":{"name":"repro","version":"0.1.0"}}}
""".trimIndent(),
)
}
response.status shouldBe HttpStatusCode.OK
val error = response.body<JSONRPCError>()
error.error.code shouldBe RPCError.ErrorCode.INVALID_PARAMS
error.error.message.contains("kotlinx.serialization") shouldBe false
}
@Test
fun `request with unsupported protocol version returns an HTTP error`() = testApplication {
configTestServer()