modelcontextprotocol / modelcontextprotocol/kotlin-sdk
GET SSE stream reconnect fails due to stale STANDALONE_SSE_STREAM_ID mapping
Nobody has claimed this yet.
- Dominant language
- Kotlin
- Stars
- 1.5k
- Forks
- 248
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 23
Description
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
/mcpwith 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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in StreamableHttpServerTransport.kt at handleGetRequest and the invokeOnCompletion cleanup around the stale STANDALONE_SSE_STREAM_ID entry. Reproduce the disconnect-and-immediate-reconnect sequence described in the issue, and compare the cleanup behavior with packages/server/src/server/streamableHttp.ts. Done means a new GET SSE stream on the same session remains live after the previous stream closes, without the stale-entry rejection.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- kotlin
- Domain
- api, backend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 67/100