modelcontextprotocol / modelcontextprotocol/kotlin-sdk
Stateful StreamableHttpServerTransport leaks the standalone GET SSE stream on session close
Nobody has claimed this yet.
- Dominant language
- Kotlin
- Stars
- 1.5k
- Forks
- 248
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 23
Description
Summary
Closing a stateful StreamableHttpServerTransport session (via DELETE, or any other path that calls close()) never cancels the coroutine handling the standalone GET SSE stream. The connection is left permanently open on the server side. Every completed session leaks one socket.
This is distinct from #786 / #872, which fixed a session-cleanup leak in the stateless transport (mcpStatelessStreamableHttp). The bug described here is in the stateful transport (mcpStreamableHttp) and is still present after that fix.
Also related to #715 / #716: that fix made the STANDALONE_SSE_STREAM_ID mapping cleanup synchronous once the GET-handling coroutine is cancelled (e.g. on client disconnect). It's already present in 0.14.0 and 0.15.0. It doesn't address the issue here, which is that close() never triggers that cancellation in the first place.
Root cause
StreamableHttpServerTransport.handleGetRequest() keeps the standalone GET stream open by suspending on awaitCancellation():
try {
awaitCancellation()
} finally {
streamsMapping.remove(STANDALONE_SSE_STREAM_ID, newContext)
}
close() (invoked from handleDeleteRequest) only closes the ServerSSESession:
streamsMapping.values.forEach {
try { it.session?.close() } catch (_: Exception) {}
}
ServerSSESession.close() (DefaultServerSSESession in ktor-server-sse) does output.flushAndClose() — it closes the write side of the response body, but it does not cancel the Job that's suspended in awaitCancellation(). That coroutine, and the ApplicationCall/socket behind it, never complete and are never released.
Reproduction
Run N sequential sessions against a stateful mcpStreamableHttp server (initialize → GET → any request → DELETE, each completing successfully), and count open sockets on the server process between rounds:
ls -la /proc/<server-pid>/fd | grep -c "socket:\["
Result (measured directly against the server process, no reverse proxy involved):
baseline: 12 sockets
after 10 sessions: 22 sockets (+10)
after 10 more: 32 sockets (+10)
Exactly one leaked socket per session, reproducible on demand. Confirmed on 0.14.0 and 0.15.0 (ktor 3.5.1, JVM 21) — handleGetRequest() is unchanged between those versions.
Impact
In isolation this is a slow resource leak that eventually exhausts the process's file descriptor limit. It becomes an acute, user-visible problem behind a reverse proxy that pools/reuses backend connections (observed with Caddy, default keepalive): once the GET response body reaches EOF (from flushAndClose()), the proxy considers that connection idle and reusable, even though the server-side coroutine handling it never returned. The proxy then hands that connection to an unrelated subsequent request, which is silently dropped — no response, no error, no timeout on the server side, until the client's own timeout fires.
Reproduced this reliably in production: the first session in a freshly started process always succeeds; every session after that hangs on the first request routed onto a reused, leaked connection.
Expected behavior
Tearing down a session should cancel the Job backing the standalone GET stream's request-handling coroutine, not just close the ServerSSESession's output channel, so the HTTP exchange actually completes from the engine's perspective and the connection is genuinely released.
Suggested fix direction
Track the Job for the GET-handling call (e.g. call.coroutineContext.job, captured when handleGetRequest starts) alongside the existing SessionContext, and cancel it explicitly in close() in addition to calling session.close().
Environment
kotlin-sdk: 0.14.0, 0.15.0ktor: 3.5.1- JVM: 21 (Corretto)
- Transport:
mcpStreamableHttp(stateful,enableJsonResponse = true)
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 with StreamableHttpServerTransport.handleGetRequest() and close(), especially the standalone SSE SessionContext and the coroutine suspended by awaitCancellation(). Trace how the GET call's Job is stored and cancelled during session teardown, then verify that sequential initialize/GET/request/DELETE sessions release their sockets and no longer reproduce the reported leak.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- kotlin
- Domain
- api, backend
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100