Skip to content

MCP session store leaks every session that never sends DELETE /mcp (709 KiB each, unbounded) #1684

Description

@daviesayo

Summary

The in-process MCP session store never frees a session unless the client sends DELETE /mcp. Most clients never send it, so every initialize permanently adds an McpServer, its tool registry, and an ExecutionEngine to four maps that have no TTL, no idle sweep, and no size bound.

I measured 709 KiB retained per abandoned session, growing linearly with no plateau. On a self-host serving roughly 157 sessions an hour, that is 0.45 GB a day. My instance went from 0.3 GB to 1.85 GB in three and a half days and would have reached the 8 GB container limit in about a fortnight.

Unconfirmed, but this could be a diagnosis for #1565, which reports the symptom without a cause.

Reproduction

Script: https://gist.github.com/daviesayo/f54b903c7abca2fef4a5564a564b8927

Both scripts open N MCP sessions over the real OAuth flow and read container RSS. Each arm runs against a pristine container.

docker run -d --name exec-repro -p 4788:4788 \
  -e EXECUTOR_BOOTSTRAP_ADMIN_EMAIL=repro@example.com \
  -e EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD='ReproPassw0rd!x' \
  ghcr.io/usefulsoftwareco/executor-selfhost:1.5.42

repro-public.py isolates the DELETE. The two arms differ in exactly one thing: whether the client sends DELETE /mcp after initialize.

python3 repro-public.py leak 500       # no DELETE
python3 repro-public.py control 500    # with DELETE
Arm RSS after 500 sessions Per session Shape
No DELETE +346 MiB +709 KiB linear, no plateau
With DELETE +13 MiB +27 KiB flat, ordinary GC sawtooth

The leak arm climbs about 35 MiB per 50 sessions at every checkpoint from 50 through 500.

reuse.py shows the memory is never returned. It fills the heap, idles long enough for any cleanup to run, then repeats the identical load.

python3 reuse.py 400 90
  batch 1:   255.7 ->   547.5 MiB   (+ 291.8)
  draining 90s ...
  after drain: 513.3 MiB
  batch 2:   513.5 ->   811.9 MiB   (+ 298.4)

Use this one to test any candidate fix. RSS is a high-water mark and the allocator does not hand pages back promptly, so a single-batch RSS reading cannot tell a leak apart from a large live working set. What distinguishes them is whether RSS comes back down when the load stops, and whether a second identical batch reaches the same ceiling or a higher one. Here it does neither.

Root cause

packages/hosts/mcp/src/in-memory-session-store.ts:177-181 holds four maps keyed by session id:

const transports = new Map<string, WebStandardStreamableHTTPServerTransport>();
const servers    = new Map<string, McpServer>();
const owners     = new Map<string, SessionOwner>();
const engines    = new Map<string, ExecutionEngine<Cause.YieldableError>>();

Entries go in from onsessioninitialized. Only three things take them out:

  1. onsessionclosed, which the SDK fires on DELETE /mcp
  2. transport.onclose
  3. close() at process shutdown

There is no fourth path. I grepped the package and apps/host-selfhost for setInterval|TTL|evict|idle|maxSessions|LRU and found nothing that touches these maps.

Why the DELETE never arrives

Three independent reasons, and the first one alone is enough:

The official client SDK does not send it. StreamableHTTPClientTransport.close() clears a reconnect timer, aborts a local AbortController, and calls onclose. It puts nothing on the wire. Only terminateSession() issues the DELETE, and Client.close() does not call it. So a well-behaved client using the documented shutdown path leaks a session on the server every time.

A crashed or killed client cannot send it. Any SIGKILL, container eviction, or network partition leaks a session.

There is no stream teardown to fall back on. The transport is constructed with enableJsonResponse: true, so responses are single JSON bodies rather than a long-lived SSE stream. Nothing ties transport lifetime to client liveness. In my production logs, clients abandon the GET /mcp SSE stream constantly (499 after 11 to 298 seconds) and the session survives every time.

In 12 hours of production HTTP logs across every client hitting my instance, I counted zero DELETE /mcp requests.

Impact

The store trusts clients to clean up after themselves, and gives itself no recourse when they do not. That makes memory growth a function of session count rather than concurrent load, so an idle-but-reachable host still climbs. It also means one misbehaving or crash-looping client can exhaust the host for every other client on it.

sessionCount() was added in 1.5.42 and reports the problem accurately, but nothing acts on it.

Environment

  • Image ghcr.io/usefulsoftwareco/executor-selfhost:1.5.42 (also present on main at aff1f394)
  • Production instance on Railway, single container, 8 GB limit
  • Clients: MCP SDK 1.29.0 over streamable HTTP, plus the Claude connector

Proposed fix

Evict on idle. Stamp each session on create and on every forwarded request, then sweep on a timer and dispose anything past a TTL, defaulting to 30 minutes.

Eviction is what the streamable HTTP spec allows a server to do, and the store already has the right behavior for it: a request carrying an evicted id falls through to the existing "not-found" result, which the envelope renders as 404 -32001, and the client re-initializes.

PR: #1685. It also plumbs an EXECUTOR_MCP_SESSION_IDLE_TTL_MS knob through the self-host so an operator can tune the window, or set 0 to restore the current behavior.

Under reuse.py, the patched build drops to 197.8 MiB after the drain, below its own starting point, and its second batch reaches the same ceiling as the first rather than a higher one.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions