Skip to content

fix: release MCP session state so the server stops leaking heap - #85

Open
younsl wants to merge 2 commits into
kagent-dev:mainfrom
younsl:fix/mcp-session-leak
Open

younsl wants to merge 2 commits into
kagent-dev:mainfrom
younsl:fix/mcp-session-leak

Conversation

@younsl

@younsl younsl commented Sep 17, 2026

Copy link
Copy Markdown

What this changes

The streamable HTTP server registers an MCP session on initialize and never released it, so heap grew with the number of sessions ever created until the pod was OOMKilled.

  • Bump github.com/mark3labs/mcp-go from v0.43.2 to v1.1.0, which unregisters the session in handleDelete (mark3labs/mcp-go#724, released in v0.44.1).
  • Enable the idle sweeper with server.WithSessionIdleTTL, so sessions abandoned without a DELETE are reclaimed as well. It is opt-in even on v1.1.0 (the default is zero, which disables it), so the dependency bump alone does not fix POST-only clients.
  • Add --session-idle-ttl, default 10m, 0 disables the sweeper.
  • Stop the sweeper and close still-registered sessions during graceful shutdown.
  • Run the cmd package tests in make test. They were excluded, so nothing in cmd/ was covered by CI, including the regression tests added here.

Why the dependency bump alone is not enough

On v0.43.2 the session is stored in activeSessions and in server.sessions on initialize, but handleDelete only clears the per-session tool, resource, log-level and request-ID stores. UnregisterSession is reached on the GET SSE path only, so a POST-only client, which is the normal request/response mode, retains every session it ever opened.

v1.1.0 fixes the DELETE path, but a client that crashes, is restarted, or has its connection dropped by a load balancer never sends one. The sweeper is what bounds memory in that case.

Verification

Eight bursts of 500 initialize requests, 15s apart, against both binaries. Live objects are go_memstats_heap_objects taken as the minimum of 25 samples, which approximates the post-GC live set; the GC counter confirms collections ran between samples (6 at start, 15 and 34 at the end of the two runs).

cumulative sessions 0 500 1000 1500 2000 2500 3000 3500 4000
v0.43.2 live objects 18.5k 27.3k 35.8k 51.0k 59.8k 60.9k 126.9k 86.0k 169.0k
v0.43.2 heap 3.45 MB 9.51 13.50 18.87 23.11 25.12 37.43 35.88 48.47 MB
this change live objects 20.2k 21.2k 35.0k 34.4k 21.5k 21.6k 39.8k 33.6k 21.7k
this change heap 3.90 MB 4.01 11.10 10.98 4.05 3.97 4.10 10.94 4.09 MB

On v0.43.2 the live set climbs with cumulative sessions and never returns. With this change it oscillates around its starting value and comes back to it: 21.7k objects and 4.09 MB after 4,000 sessions, against 20.2k and 3.90 MB before the first request.

Tests added in cmd/streamable_http_test.go assert the behavior directly rather than through heap numbers, using server.Hooks to observe session registration:

  • a session ended with DELETE is released
  • a session abandoned without a DELETE is released by the sweeper

Also ran locally: make build, make test-only (all packages pass), make helm-test (23 tests pass), gofmt -l ., go vet, go fix. make lint fails on this machine, but it fails identically on main with the same linter binary: golangci-lint v1.63.4 reports undefined: By and similar typecheck errors for the ginkgo dot-imports in test/e2e under a newer local Go toolchain. E2E was not run; it needs a Kind cluster.

Operator impact

Default behavior changes: session state idle for more than 10 minutes is now reclaimed. A session reclaimed while its client is still alive is re-established on the next initialize. Deployments that want a different value can set it through tools.args in the Helm chart, and --session-idle-ttl=0 restores the previous, unbounded behavior.

pprof on the metrics port, item 3 of the proposed fix in the issue, is left out to keep this change focused. Happy to open a separate PR for it.

Closes #84

The streamable HTTP server registers a session on `initialize` and, on
mcp-go v0.43.2, never releases it: `handleDelete` clears the per-session
stores but never calls `UnregisterSession`, so every session a client ever
opened is retained. A POST-only client leaks roughly 8 KB per initialize,
which in production shows up as linear heap growth until the container is
OOMKilled.

Bump mcp-go to v1.1.0, which unregisters the session on DELETE, and enable
its idle sweeper so sessions abandoned without a DELETE are reclaimed too.
The sweeper is opt-in even on v1.1.0, so the bump alone is not enough. The
new `--session-idle-ttl` flag defaults to 10m and 0 disables it. Shutdown
now stops the sweeper and closes sessions that are still registered.

Measured over repeated bursts of 500 initialize requests, 15s apart:

  mcp-go v0.43.2   3.44 -> 7.74 -> 12.96 -> 17.96 -> 21.65 MB live heap
  this change      3.93 -> 9.55 ->  9.63 ->  9.55 ->  9.55 MB live heap

Closes kagent-dev#84

Signed-off-by: younsl <cysl@kakao.com>
`make test` covered ./pkg/... and ./internal/... only, so the tests in cmd/
never ran in CI. That includes the session lifecycle regression tests this
branch adds, which is the code path that leaked.

Signed-off-by: younsl <cysl@kakao.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Streamable HTTP server leaks ~10KB of heap per MCP initialize request, never freed (mcp-go v0.43.2 predates the session cleanup fix)

1 participant