Skip to content

fix(host-mcp): evict idle MCP sessions instead of leaking them - #1685

Merged
RhysSullivan merged 7 commits into
UsefulSoftwareCo:mainfrom
daviesayo:fix/mcp-session-idle-eviction
Aug 28, 2026
Merged

fix(host-mcp): evict idle MCP sessions instead of leaking them#1685
RhysSullivan merged 7 commits into
UsefulSoftwareCo:mainfrom
daviesayo:fix/mcp-session-idle-eviction

Conversation

@daviesayo

Copy link
Copy Markdown
Contributor

Fixes #1684.

What this does

The in-process MCP session store now evicts sessions that have gone idle, instead of holding every session it ever created until the process exits.

Each session gets a last-seen stamp on create and on every forwarded request. A timer sweeps the stamps and disposes anything past sessionIdleTtlMs, which defaults to 30 minutes. The self-host exposes EXECUTOR_MCP_SESSION_IDLE_TTL_MS so an operator can widen the window, or set 0 to turn eviction off.

Why

onsessionclosed fires on DELETE /mcp, and nothing sends that DELETE.

  • The client SDK's StreamableHTTPClientTransport.close() clears a timer, aborts a local AbortController, and calls onclose. It puts nothing on the wire. Only terminateSession() sends the DELETE, and Client.close() does not call it.
  • A crashed or SIGKILLed client cannot send it at all.
  • enableJsonResponse: true means there is no long-lived stream whose teardown could stand in for it.

So a session that the client has finished with stays in transports, servers, owners, and engines, pinning an McpServer, its tool registry, and an ExecutionEngine. Across 12 hours of production logs on my self-host I counted zero DELETE /mcp requests against 157 new sessions an hour.

Evicting is what the streamable HTTP spec allows a server to do, and the store already behaves correctly when it happens: an unknown session id falls through to the existing "not-found", which the envelope renders as 404 -32001, and the client re-initializes.

What I ran

Both scripts are in the gist on #1684. The load is identical in every arm: N sessions, client never sends DELETE.

Retention. Fill the heap, idle long enough for the sweep to drain, then repeat the identical load. RSS in MiB:

Image after batch 1 after 90s idle after batch 2
1.5.42 547.5 513.3 811.9
this branch 498.6 197.8 485.6

1.5.42 keeps climbing across batches and never gives the memory back. This branch returns below its own starting point and reaches the same ceiling on the second batch rather than a higher one. The in-batch rise is the live working set: 400 sessions opened in about two minutes against a 15-second TTL are legitimately alive at once.

Eviction behaves as designed. With a 20-second TTL, a session answers 200 immediately and 404 -32001 after 75 seconds.

Gates. in-memory-session-store.test.ts 3/3 with a new test covering evict-the-idle-one, keep-the-busy-one, and 404-after-eviction. apps/host-selfhost mcp and config suites 17/17. tsgo --noEmit and oxlint clean on every changed file.

packages/hosts/mcp is 202/203. The one failure is stdio-integration.test.ts, which fails the same way on unpatched main at aff1f394, so it is not from this change.

What I did not check

I have not run the full monorepo suite, only the two packages this touches. I have not exercised the Cloudflare Durable Object store, which has its own lifecycle and is untouched here.

Choices worth a look

30 minutes as the default. Long enough that a polling client (mine reconnects about every five minutes) never trips it, short enough that an abandoned session does not outlive the working day. Happy to change it.

The sweep swallows its own failures. It follows ignoreClose: a failed sweep must not surface as an unhandled rejection in a host that is otherwise healthy. The timer is unref'd so it never keeps a process alive by itself.

Eviction closes the transport as well as the server, unlike the onsessionclosed path which passes only { server: true }. An evicted session's transport has no other owner, and leaving it open would hold the handles the eviction exists to release. I left the DELETE path alone rather than widen the scope here.

The in-process session store keyed transports, servers, owners, and engines
by session id and only ever deleted an entry on `onsessionclosed`, which the
SDK fires on `DELETE /mcp`. Nothing sends that DELETE: the client SDK's
`transport.close()` aborts locally and puts nothing on the wire, a crashed
client cannot send it, and `enableJsonResponse` leaves no stream whose
teardown could stand in for it. Every initialize therefore pinned an
McpServer, its tool registry, and an ExecutionEngine until the process exited.

Measured against ghcr.io/usefulsoftwareco/executor-selfhost:1.5.42, 500
sessions opened without a DELETE grow RSS by 346 MiB (709 KiB each, linear,
no plateau); the same 500 with a DELETE grow it by 13 MiB.

Stamp each session on create and on every forwarded request, then sweep on a
timer and dispose anything idle past the TTL. Eviction is what the streamable
HTTP spec allows a server to do, and the store already renders an unknown id
as the existing "not-found" (404 -32001), which is a client's cue to
re-initialize.
The store's idle window is only useful if an operator can tune it: a client
that cannot tolerate re-initializing needs a longer TTL, and diagnosing one
needs eviction off entirely (0). Parse it the same way as
EXECUTOR_SANDBOX_TIMEOUT_MS, refusing to boot on a malformed value.
Resolutions:
- in-memory-session-store.ts: main dropped requestStateSigningKey; keep that
  removal alongside the branch lastSeen/touch/sweep additions.
- in-memory-session-store.test.ts: main rewrote this file down to a single
  case, deleting the buildMcpServer and eliciting-engine helpers the branch
  test used. Rebuild the eviction case on createExecutorMcpServer and drive
  sweepIdleSessions with an explicit instant instead of real sleeps.
…ationale

makeSelfHostMcpSeams called loadConfig() inside the factory, hiding an env
read behind construction and moving a boot-time throw (loadConfig refuses a
malformed EXECUTOR_MCP_SESSION_IDLE_TTL_MS) off the boot path. The app already
has the resolved config, so pass it in.

The store comment claimed enableJsonResponse means no long-lived stream
exists. It only governs how a POST carrying requests answers; the bare 202 for
notifications/initialized still cues the client to open the GET stream, so
nearly every session holds one. Eviction ignores it on purpose, which is what
cloud already does past its running-lease ceiling.
… down

The idle sweep read only a last-seen stamp, written before the store awaits
transport.handleRequest. A call slower than the idle window was therefore
indistinguishable from an abandoned session, and the sweep closed the
transport, the server, and the engine underneath the request still using them.
Count the requests inside handleRequest per session, skip a session with any,
and restamp when a call ends so idleness measures from completion.

Disposal deleted the engine reference without running engine.shutdown, so the
detached sandbox fibers a paused execution holds kept running - and kept
querying the host database handle - after the session was gone. Every disposal
path now shuts the engine down: sweep eviction, the dispose seam, store close,
onsessionclosed, and the eager close of a transport that never minted an id.

A close failure was swallowed whole, which made a leaked handle invisible. Keep
it best-effort, but log it at warning with the session id and the handle.

Wire the store close hook into the self-host server. startServer discarded
closeDb, so a graceful shutdown left every live session and the libSQL handle
open; only the test web-handler path ever released them.
@RhysSullivan
RhysSullivan merged commit 2b4e106 into UsefulSoftwareCo:main Aug 28, 2026
40 checks passed
@RhysSullivan RhysSullivan mentioned this pull request Aug 28, 2026
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.

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

2 participants