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.
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:
onsessionclosed, which the SDK fires on DELETE /mcp
transport.onclose
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.
Summary
The in-process MCP session store never frees a session unless the client sends
DELETE /mcp. Most clients never send it, so everyinitializepermanently adds anMcpServer, its tool registry, and anExecutionEngineto 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.
repro-public.pyisolates the DELETE. The two arms differ in exactly one thing: whether the client sendsDELETE /mcpafterinitialize.DELETEDELETEThe leak arm climbs about 35 MiB per 50 sessions at every checkpoint from 50 through 500.
reuse.pyshows the memory is never returned. It fills the heap, idles long enough for any cleanup to run, then repeats the identical load.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-181holds four maps keyed by session id:Entries go in from
onsessioninitialized. Only three things take them out:onsessionclosed, which the SDK fires onDELETE /mcptransport.oncloseclose()at process shutdownThere is no fourth path. I grepped the package and
apps/host-selfhostforsetInterval|TTL|evict|idle|maxSessions|LRUand 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 localAbortController, and callsonclose. It puts nothing on the wire. OnlyterminateSession()issues theDELETE, andClient.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 theGET /mcpSSE stream constantly (499after 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 /mcprequests.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
ghcr.io/usefulsoftwareco/executor-selfhost:1.5.42(also present onmainataff1f394)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 as404 -32001, and the client re-initializes.PR: #1685. It also plumbs an
EXECUTOR_MCP_SESSION_IDLE_TTL_MSknob through the self-host so an operator can tune the window, or set0to 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.