Describe the bug
When the Kotlin SDK's Streamable HTTP transport rejects a request after parsing the JSON-RPC body, the error response can omit the JSON-RPC id field. One reproducible case is a duplicate initialize request on an existing session.
Error responses MUST include the same ID as the request they correspond to (except in error cases where the ID could not be read due a malformed request).
In the observed case, the request body has already been parsed and the request id is available, so this is not a case where the ID could not be read.
The concrete reproduction below uses the duplicate-initialize path. In that path, the transport-level reject() helper serializes the error with id = null. Other call sites that invoke the same helper after successfully parsing a request body may have the same response-correlation issue. The same duplicate-initialize request rejected via stdio produces an error response with the id present.
- Environment
- Reproduced with stable release
0.12.0 (c339d8cb8656ae419d6149b3342a568ba0119351)
- Transport: Streamable HTTP, stateful mode
To reproduce
- Start a Kotlin SDK MCP server over Streamable HTTP.
- Complete a normal
initialize handshake to establish a session.
- Send a second
initialize request on the same session:
{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{"roots":{"listChanged":true},"sampling":{},"elicitation":{}},"clientInfo":{"name":"repro","version":"0.1.0"}}}
- Observe the HTTP response body.
Expected behavior
The error response should include the same id as the request. For example:
{"jsonrpc":"2.0","id":0,"error":{"code":-32600,"message":"Invalid Request: Server already initialized"}}
Observed behavior
The error response omits id:
{"error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"jsonrpc":"2.0"}
The HTTP status code is 400. The server remains healthy and responds normally to subsequent requests.
Additional context
In StreamableHttpServerTransport.kt, the reject() extension function constructs transport-level JSON-RPC errors with id = null:
internal suspend fun ApplicationCall.reject(status: HttpStatusCode, code: Int, message: String) {
this.response.status(status)
this.respond(JSONRPCError(id = null, error = RPCError(code = code, message = message)))
}
Because reject() does not accept an id parameter, callers cannot preserve the request id even when the JSON-RPC body has already been parsed.
In the duplicate-initialize path, the request body has already been parsed and the id is available on the parsed JSONRPCRequest, but the rejection still goes through reject() without the id:
if (initialized.load() && sessionId != null) {
call.reject(
HttpStatusCode.BadRequest,
RPCError.ErrorCode.INVALID_REQUEST,
"Invalid Request: Server already initialized",
)
return
}
The same helper is used by other transport-level validations. Any path that calls it after successfully parsing a JSON-RPC request would omit the request id unless the id is passed into the error response.
As a related data point, #588 fixed the client-side counterpart — the client previously failed to deserialize responses without an id field. That fix made the client tolerant of missing id, but the server-side generation of responses without id was not addressed.
Describe the bug
When the Kotlin SDK's Streamable HTTP transport rejects a request after parsing the JSON-RPC body, the error response can omit the JSON-RPC
idfield. One reproducible case is a duplicateinitializerequest on an existing session.In the observed case, the request body has already been parsed and the request
idis available, so this is not a case where the ID could not be read.The concrete reproduction below uses the duplicate-initialize path. In that path, the transport-level
reject()helper serializes the error withid = null. Other call sites that invoke the same helper after successfully parsing a request body may have the same response-correlation issue. The same duplicate-initialize request rejected via stdio produces an error response with theidpresent.0.12.0(c339d8cb8656ae419d6149b3342a568ba0119351)To reproduce
initializehandshake to establish a session.initializerequest on the same session:{"jsonrpc":"2.0","id":0,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{"roots":{"listChanged":true},"sampling":{},"elicitation":{}},"clientInfo":{"name":"repro","version":"0.1.0"}}}Expected behavior
The error response should include the same
idas the request. For example:{"jsonrpc":"2.0","id":0,"error":{"code":-32600,"message":"Invalid Request: Server already initialized"}}Observed behavior
The error response omits
id:{"error":{"code":-32600,"message":"Invalid Request: Server already initialized"},"jsonrpc":"2.0"}The HTTP status code is 400. The server remains healthy and responds normally to subsequent requests.
Additional context
In
StreamableHttpServerTransport.kt, thereject()extension function constructs transport-level JSON-RPC errors withid = null:Because
reject()does not accept anidparameter, callers cannot preserve the request id even when the JSON-RPC body has already been parsed.In the duplicate-initialize path, the request body has already been parsed and the
idis available on the parsedJSONRPCRequest, but the rejection still goes throughreject()without the id:The same helper is used by other transport-level validations. Any path that calls it after successfully parsing a JSON-RPC request would omit the request id unless the id is passed into the error response.
As a related data point, #588 fixed the client-side counterpart — the client previously failed to deserialize responses without an
idfield. That fix made the client tolerant of missingid, but the server-side generation of responses withoutidwas not addressed.