Skip to content

Streamable HTTP transport omits JSON-RPC id when rejecting parsed requests #866

Description

@cclabadmin

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

  1. Start a Kotlin SDK MCP server over Streamable HTTP.
  2. Complete a normal initialize handshake to establish a session.
  3. 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"}}}
  1. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Moderate issues, valuable feature requestsbugSomething isn't workingready for workHas enough information to start

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions