modelcontextprotocol / modelcontextprotocol/kotlin-sdk

Stateful StreamableHttpServerTransport leaks the standalone GET SSE stream on session close

Open Beginner friendly
#922 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug P1 ready for work
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 (initializeGET → 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.0
  • ktor: 3.5.1
  • JVM: 21 (Corretto)
  • Transport: mcpStreamableHttp (stateful, enableJsonResponse = true)

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.