fix(server): preserve id on duplicate initialize errors (#868)

## Summary

Fixes #866.

## Changes

- Preserve the parsed JSON-RPC request id when Streamable HTTP rejects a
duplicate `initialize` request.
- Keep pre-parse and transport-level rejection paths unchanged when the
request id is not available.
- Extend the duplicate-initialize regression test to assert the error
response id as well as the HTTP status.

## Verification

```bash
./gradlew :kotlin-sdk-server:jvmTest --tests 'io.modelcontextprotocol.kotlin.sdk.server.StreamableHttpServerTransportTest.second initialization request returns JSON-RPC error with request id'
./gradlew :kotlin-sdk-server:jvmTest --tests 'io.modelcontextprotocol.kotlin.sdk.server.StreamableHttpServerTransportTest'
./gradlew :kotlin-sdk-server:jvmTest
./gradlew :kotlin-sdk-server:ktlintCheck :kotlin-sdk-server:detekt
```

Co-authored-by: Pavel Gorgulov <devcrocod@gmail.com>
This commit is contained in:
King Star 2026-07-22 21:53:01 +08:00 • committed by GitHub
commit b6cb547d58
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 19 additions and 8 deletions

View file

@ -459,9 +459,10 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
}
val messages = parseBody(call) ?: return
val isInitializationRequest = messages.any {
it is JSONRPCRequest && it.method == Method.Defined.Initialize.value
val initializationRequest = messages.filterIsInstance<JSONRPCRequest>().firstOrNull {
it.method == Method.Defined.Initialize.value
}
val isInitializationRequest = initializationRequest != null
if (isInitializationRequest) {
if (initialized.load() && sessionId != null) {
@ -469,6 +470,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
HttpStatusCode.BadRequest,
RPCError.ErrorCode.INVALID_REQUEST,
"Invalid Request: Server already initialized",
initializationRequest.id,
)
return
}
@ -506,8 +508,7 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
// For initialize requests, get from request params.
// For other requests, get from header (already validated).
val clientProtocolVersion = if (isInitializationRequest) {
val initRequest = messages.first() as JSONRPCRequest
(initRequest.params as? JsonObject)?.get("protocolVersion")
(initializationRequest.params as? JsonObject)?.get("protocolVersion")
?.let { McpJson.decodeFromJsonElement<String>(it) }
?: DEFAULT_NEGOTIATED_PROTOCOL_VERSION
} else {
@ -913,7 +914,12 @@ public class StreamableHttpServerTransport(private val configuration: Configurat
}
}
internal suspend fun ApplicationCall.reject(status: HttpStatusCode, code: Int, message: String) {
internal suspend fun ApplicationCall.reject(
status: HttpStatusCode,
code: Int,
message: String,
id: RequestId? = null,
) {
this.response.status(status)
this.respond(JSONRPCError(id = null, error = RPCError(code = code, message = message)))
this.respond(JSONRPCError(id = id, error = RPCError(code = code, message = message)))
}

View file

@ -38,6 +38,7 @@ import io.modelcontextprotocol.kotlin.sdk.types.Implementation
import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequest
import io.modelcontextprotocol.kotlin.sdk.types.InitializeRequestParams
import io.modelcontextprotocol.kotlin.sdk.types.InitializedNotification
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
@ -163,7 +164,7 @@ class StreamableHttpServerTransportTest {
}
@Test
fun `second initialization request returns an HTTP error`() = testApplication {
fun `second initialization request returns JSON-RPC error with request id`() = testApplication {
configTestServer()
val client = createTestClient()
@ -186,13 +187,17 @@ class StreamableHttpServerTransportTest {
firstResponse.status shouldBe HttpStatusCode.OK
val secondRequest = buildInitializeRequestPayload().copy(id = RequestId("second-init"))
val secondResponse = client.post(path) {
addStreamableHeaders()
header("mcp-session-id", firstResponse.headers[MCP_SESSION_ID_HEADER])
setBody(payload)
setBody(secondRequest)
}
secondResponse.status shouldBe HttpStatusCode.BadRequest
val error = secondResponse.body<JSONRPCError>()
error.id shouldBe secondRequest.id
error.error.message shouldBe "Invalid Request: Server already initialized"
}
@Test