Skip to content

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

Open
daviesayo wants to merge 2 commits into
UsefulSoftwareCo:mainfrom
daviesayo:fix/mcp-session-idle-eviction
Open

fix(host-mcp): evict idle MCP sessions instead of leaking them#1685
daviesayo wants to merge 2 commits into
UsefulSoftwareCo:mainfrom
daviesayo:fix/mcp-session-idle-eviction

Conversation

@daviesayo

Copy link
Copy Markdown

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.
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)

1 participant