Skip to content

GET SSE stream reconnect fails due to stale STANDALONE_SSE_STREAM_ID mapping #715

@amr

Description

@amr

Describe the bug

When a GET SSE stream disconnects and the client reconnects on the same session, the new GET is silently rejected because the previous stream's STANDALONE_SSE_STREAM_ID entry in streamsMapping hasn't been
cleaned up yet. Clients see a 200 OK with an immediately-closed empty SSE stream, causing a retry loop that manifests as a permanently lost MCP connection.

To Reproduce

  1. Start a Streamable HTTP MCP server using mcpStreamableHttp
  2. Initialize a session via POST to /mcp
  3. Open a GET SSE stream on /mcp, consume the flush event, then close the connection
  4. Immediately open a new GET SSE stream on /mcp with the same session ID
  5. The new stream is dead — first readLine() returns null

Expected behavior

The reconnected GET SSE stream should be live and functional. The TypeScript reference implementation handles this correctly because ReadableStream.cancel() removes the stream mapping synchronously on
disconnect.

Logs

Server-side CallLogging reports 409 Conflict, but this status never reaches the client (Ktor's sse {} handler commits 200 OK before the transport handler runs):

  13:47:05.141 WARN  io.ktor.server.Application - GET /mcp → 409 Conflict (session=d4aed42f-86af-49da-ab1d-3b8b61f9ad13)
  13:47:05.339 WARN  io.ktor.server.Application - GET /mcp → 409 Conflict (session=9601a9a7-0583-43bb-9c0f-1664c113f771)
  13:47:05.356 WARN  io.ktor.server.Application - GET /mcp → 409 Conflict (session=5092cfe4-c66a-4609-8fde-1d9017307818)
  13:47:06.142 WARN  io.ktor.server.Application - GET /mcp → 409 Conflict (session=d4aed42f-86af-49da-ab1d-3b8b21f9ad13)
  13:47:06.340 WARN  io.ktor.server.Application - GET /mcp → 409 Conflict (session=9601a9a7-0583-43bb-9c0f-1664c113f771)
  13:47:06.358 WARN  io.ktor.server.Application - GET /mcp → 409 Conflict (session=5092cfe4-c66a-4609-8fde-1d9017307818)

Three concurrent MCP client sessions, each retrying ~1/second indefinitely.

Additional context

The TypeScript reference implementation (packages/server/src/server/streamableHttp.ts) cleans up the stream mapping synchronously via ReadableStream.cancel()`:

cancel: () => {
    this._streamMapping.delete(this._standaloneSseStreamId);
}

The Kotlin SDK (StreamableHttpServerTransport.kt line) uses invokeOnCompletion, which is asynchronous:

  sseSession.coroutineContext.job.invokeOnCompletion {
      streamsMapping.remove(STANDALONE_SSE_STREAM_ID)
  }

Between the client disconnect and the invokeOnCompletion callback firing, there is a race window where handleGetRequest finds the stale entry at line 467 and rejects. In practice with Ktor (both CIO and Netty
engines), this window is long enough that reconnects consistently fail.

There is a secondary issue: Ktor's sse {} handler commits 200 OK headers before calling handleGetRequest, so the call.reject(HttpStatusCode.Conflict, ...) at line 468-473 can never deliver a 409 to the client.
The rejection manifests as an empty/closed SSE stream rather than a proper HTTP error response. The 409 in server logs comes from CallLogging reading the response object, not the wire.

This bug likely became reachable after #681 (April 10) fixed the Netty appendSseHeaders crash that previously prevented GET SSE from working at all on Netty.

Tested against latest main at bf8dc6d.

Written with the help of Claude Code Opus 4.7.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Significant bug affecting many usersbugSomething isn't working

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions