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
- Start a Streamable HTTP MCP server using
mcpStreamableHttp
- Initialize a session via POST to
/mcp
- Open a GET SSE stream on
/mcp, consume the flush event, then close the connection
- Immediately open a new GET SSE stream on
/mcp with the same session ID
- 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.
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_IDentry instreamsMappinghasn't beencleaned 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
mcpStreamableHttp/mcp/mcp, consume the flush event, then close the connection/mcpwith the same session IDreadLine()returns nullExpected 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 ondisconnect.
Logs
Server-side
CallLoggingreports 409 Conflict, but this status never reaches the client (Ktor'ssse {}handler commits 200 OK before the transport handler runs):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 viaReadableStream.cancel()`: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
invokeOnCompletioncallback firing, there is a race window wherehandleGetRequestfinds the stale entry at line 467 and rejects. In practice with Ktor (both CIO and Nettyengines), this window is long enough that reconnects consistently fail.
There is a secondary issue: Ktor's
sse {}handler commits 200 OK headers before callinghandleGetRequest, so thecall.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
CallLoggingreading the response object, not the wire.This bug likely became reachable after #681 (April 10) fixed the Netty
appendSseHeaderscrash 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.