From 016420c360792175798e461926df8dd6cc60e4cb Mon Sep 17 00:00:00 2001 From: Davies Ayo Date: Wed, 19 Aug 2026 23:03:31 +1000 Subject: [PATCH 1/2] fix(host-mcp): evict idle MCP sessions instead of leaking them 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. --- .changeset/mcp-session-idle-eviction.md | 5 + .../mcp/src/in-memory-session-store.test.ts | 100 ++++++++++++++++++ .../hosts/mcp/src/in-memory-session-store.ts | 74 ++++++++++++- 3 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 .changeset/mcp-session-idle-eviction.md diff --git a/.changeset/mcp-session-idle-eviction.md b/.changeset/mcp-session-idle-eviction.md new file mode 100644 index 000000000..e7c075d65 --- /dev/null +++ b/.changeset/mcp-session-idle-eviction.md @@ -0,0 +1,5 @@ +--- +"@executor-js/host-mcp": patch +--- + +Evict idle MCP sessions instead of holding them for the lifetime of the process. The in-process session store only released a session when the client sent `DELETE /mcp`, which the MCP client SDK's `transport.close()` never sends and a crashed client cannot send, so every `initialize` permanently retained an `McpServer`, its tool registry, and an `ExecutionEngine`. Sessions are now stamped on create and on each request, and a timer disposes anything idle past `sessionIdleTtlMs` (30 minutes by default). diff --git a/packages/hosts/mcp/src/in-memory-session-store.test.ts b/packages/hosts/mcp/src/in-memory-session-store.test.ts index 6c61e9bca..3118de164 100644 --- a/packages/hosts/mcp/src/in-memory-session-store.test.ts +++ b/packages/hosts/mcp/src/in-memory-session-store.test.ts @@ -112,6 +112,106 @@ describe("in-memory MCP session store", () => { expect(buildOptions?.requestStateSigningKey).toBeInstanceOf(Uint8Array); }); + it("evicts a session that goes idle past the TTL and keeps a busy one", async () => { + const { engine } = makeElicitingEngine(); + const sessions = makeInMemoryMcpSessionStore( + (_principal, options) => + buildMcpServer({ + engine, + ...options, + loadAppShellHtml: async () => "", + }).pipe(Effect.map((mcpServer) => ({ mcpServer, engine }))), + { sessionIdleTtlMs: 300 }, + ); + + const open = async (): Promise => { + const response = (await Effect.runPromise( + sessions.store.dispatch({ + request: new Request("https://executor.test/mcp", { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "idle-test", version: "1.0.0" }, + }, + }), + }), + principal: TEST_PRINCIPAL, + resource: defaultMcpResource, + sessionId: null, + method: "POST", + }), + )) as Response; + expect(response.status).toBe(200); + const sessionId = response.headers.get("mcp-session-id") ?? ""; + expect(sessionId).not.toBe(""); + return sessionId; + }; + + // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: always close the store + try { + const idle = await open(); + const busy = await open(); + expect(sessions.sessionCount()).toBe(2); + + // Neither is stale yet, so a sweep now must not touch them. + expect(await sessions.sweepIdleSessions()).toBe(0); + expect(sessions.sessionCount()).toBe(2); + + // Let both age past the idle window, then keep working on one of them: + // `forward` restamps that session and only that session. + await new Promise((resolve) => setTimeout(resolve, 500)); + await Effect.runPromise( + sessions.store.dispatch({ + request: new Request("https://executor.test/mcp", { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + "mcp-session-id": busy, + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }), + }), + principal: TEST_PRINCIPAL, + resource: defaultMcpResource, + sessionId: busy, + method: "POST", + }), + ); + + // The idle session is now well past the window and the busy one was just + // restamped, so the sweep takes exactly one. + expect(await sessions.sweepIdleSessions()).toBe(1); + expect(sessions.sessionCount()).toBe(1); + + // The evicted id is gone; the store reports it the way the envelope 404s. + const afterEviction = await Effect.runPromise( + sessions.store.dispatch({ + request: new Request("https://executor.test/mcp", { + method: "POST", + headers: { "content-type": "application/json", "mcp-session-id": idle }, + body: JSON.stringify({ jsonrpc: "2.0", id: 3, method: "tools/list" }), + }), + principal: TEST_PRINCIPAL, + resource: defaultMcpResource, + sessionId: idle, + method: "POST", + }), + ); + expect(afterEviction).toBe("not-found"); + } finally { + await sessions.close(); + } + }); + it("serves a legacy client with live Apps capabilities, elicitation, and reuse", async () => { const { engine, resumedWith } = makeElicitingEngine(); const sessions = makeInMemoryMcpSessionStore((_principal, options) => diff --git a/packages/hosts/mcp/src/in-memory-session-store.ts b/packages/hosts/mcp/src/in-memory-session-store.ts index 64ca5f9d9..856185682 100644 --- a/packages/hosts/mcp/src/in-memory-session-store.ts +++ b/packages/hosts/mcp/src/in-memory-session-store.ts @@ -54,6 +54,23 @@ import { mcpRequestStatePrincipal, type BrowserApprovalStore } from "./tool-serv // - "forbidden" (session owned by another bearer) -> envelope renders 403 -32003 // --------------------------------------------------------------------------- +// A streamable-HTTP session only leaves these maps when the client sends +// `DELETE /mcp`. Nothing else can free it: with `enableJsonResponse` there is no +// stream whose teardown signals the client is gone, and a client that crashes, +// is killed, or simply calls the MCP SDK's `transport.close()` (which aborts +// locally and sends nothing) never issues that DELETE. Without a sweep, one +// abandoned session pins its `McpServer`, its tool registry, and its +// `ExecutionEngine` for the lifetime of the process. +// +// So the store treats a session as abandoned once it has gone `idleTtlMs` +// without a request and disposes it. That is what the streamable-HTTP spec +// allows a server to do: a request carrying an evicted id gets the store's +// existing "not-found" (404, -32001), which is the client's cue to re-initialize. +/** Idle window after which an untouched session is evicted. */ +const DEFAULT_SESSION_IDLE_TTL_MS = 30 * 60 * 1000; +/** Floor on the sweep interval, so a small TTL cannot spin the timer. */ +const MIN_SWEEP_INTERVAL_MS = 30 * 1000; + /** Engine construction failed for a principal. The store surfaces it as a 500. */ export class McpEngineBuildError extends Data.TaggedError("McpEngineBuildError")<{ readonly cause: unknown; @@ -116,6 +133,11 @@ export interface InMemoryMcpSessionStore { ) => Promise; /** Number of live initialized sessions currently owned by this store. */ readonly sessionCount: () => number; + /** + * Dispose every session idle past the store's TTL and return how many went. + * Runs on a timer; exposed so a host (or a test) can drive it directly. + */ + readonly sweepIdleSessions: (now?: number) => Promise; /** Dispose every live session — wire into the host's shutdown (not a seam). */ readonly close: () => Promise; } @@ -172,7 +194,13 @@ export const makeInMemoryMcpSessionStore = ( // proxy) it is preferred over the request URL — whose host would be the // internal bind address (127.0.0.1:PORT), unreachable for the user. Omit it on // loopback hosts (local/desktop), where the request URL is already correct. - options: { readonly webBaseUrl?: string } = {}, + options: { + readonly webBaseUrl?: string; + /** Idle window before a session is evicted. 0 disables eviction. */ + readonly sessionIdleTtlMs?: number; + /** How often the sweep runs. Defaults to a quarter of the TTL. */ + readonly sessionSweepIntervalMs?: number; + } = {}, ): InMemoryMcpSessionStore => { const transports = new Map(); const servers = new Map(); @@ -180,6 +208,17 @@ export const makeInMemoryMcpSessionStore = ( const engines = new Map>(); const approvals: InProcessBrowserApprovalStore = makeInProcessBrowserApprovalStore(); const requestStateSigningKey = crypto.getRandomValues(new Uint8Array(32)); + // Monotonic-ish last-touch stamp per live session, the only input the idle + // sweep reads. Written on create and on every forwarded request. + const lastSeen = new Map(); + + const idleTtlMs = options.sessionIdleTtlMs ?? DEFAULT_SESSION_IDLE_TTL_MS; + const sweepIntervalMs = + options.sessionSweepIntervalMs ?? Math.max(MIN_SWEEP_INTERVAL_MS, Math.floor(idleTtlMs / 4)); + + const touch = (id: string): void => { + if (lastSeen.has(id)) lastSeen.set(id, Date.now()); + }; const dispose = async (id: string, opts: { transport?: boolean; server?: boolean } = {}) => { const transport = transports.get(id); @@ -188,6 +227,7 @@ export const makeInMemoryMcpSessionStore = ( servers.delete(id); owners.delete(id); engines.delete(id); + lastSeen.delete(id); if (opts.transport) await ignoreClose(transport ? () => transport.close() : undefined); if (opts.server) await ignoreClose(server ? () => server.close() : undefined); }; @@ -228,6 +268,7 @@ export const makeInMemoryMcpSessionStore = ( const owner = owners.get(sessionId); if (!transport || !owner) return Effect.succeed("not-found"); if (!sessionOwnerMatches(owner, principal, resource)) return Effect.succeed("forbidden"); + touch(sessionId); return runHandleRequest(transport, request); }; @@ -290,6 +331,7 @@ export const makeInMemoryMcpSessionStore = ( servers.set(sid, mcpServer); owners.set(sid, { principal, resource }); engines.set(sid, engine); + lastSeen.set(sid, Date.now()); }, onsessionclosed: (sid) => void dispose(sid, { server: true }), }); @@ -397,12 +439,42 @@ export const makeInMemoryMcpSessionStore = ( }); }; + /** Dispose every session whose last request is older than the idle window. */ + const sweepIdleSessions = async (now: number = Date.now()): Promise => { + if (idleTtlMs <= 0) return 0; + const stale = [...lastSeen.entries()] + .filter(([, seen]) => now - seen >= idleTtlMs) + .map(([id]) => id); + // Both flags: an evicted session's transport has no other owner, and leaving + // it open would keep the very handles the eviction exists to release. + await Promise.all(stale.map((id) => dispose(id, { transport: true, server: true }))); + return stale.length; + }; + + // `unref` so the sweep never keeps a host process alive on its own. Node and + // Bun both return a Timeout with it; the DOM typing does not, hence the guard. + const sweepTimer: ReturnType | undefined = + idleTtlMs > 0 + ? setInterval(() => { + // Same shape as `ignoreClose`: a sweep failure is not the host's + // problem and must never surface as an unhandled rejection. + void Effect.runPromise( + Effect.ignore( + Effect.tryPromise({ try: () => sweepIdleSessions(), catch: () => undefined }), + ), + ); + }, sweepIntervalMs) + : undefined; + (sweepTimer as { unref?: () => void } | undefined)?.unref?.(); + return { store, handlePausedRequest, handleApprovalRequest, sessionCount: () => transports.size, + sweepIdleSessions, close: async () => { + if (sweepTimer !== undefined) clearInterval(sweepTimer); const ids = new Set([...transports.keys(), ...servers.keys()]); await Promise.all([...ids].map((id) => dispose(id, { transport: true, server: true }))); }, From 00863debad8215ec6d4a990d11b7d5e9c14ebc45 Mon Sep 17 00:00:00 2001 From: Davies Ayo Date: Wed, 19 Aug 2026 23:04:58 +1000 Subject: [PATCH 2/2] feat(host-selfhost): expose EXECUTOR_MCP_SESSION_IDLE_TTL_MS 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. --- apps/host-selfhost/src/config.ts | 19 +++++++++++++++++++ apps/host-selfhost/src/mcp/index.ts | 7 ++++++- apps/host-selfhost/src/mcp/session-store.ts | 6 +++++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/host-selfhost/src/config.ts b/apps/host-selfhost/src/config.ts index 20728fabf..63f5adc65 100644 --- a/apps/host-selfhost/src/config.ts +++ b/apps/host-selfhost/src/config.ts @@ -53,6 +53,7 @@ export interface SelfHostConfig { * minutes (the same pattern as MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS on cloud). */ readonly sandboxTimeoutMs: number | undefined; + readonly mcpSessionIdleTtlMs: number | undefined; } export const resolveDataDir = (): string => @@ -160,6 +161,7 @@ export const loadConfig = (): SelfHostConfig => { organizationName: process.env.EXECUTOR_ORG_NAME ?? "Default", orgSlug: resolveOrgSlug(), sandboxTimeoutMs: resolveSandboxTimeoutMs(), + mcpSessionIdleTtlMs: resolveMcpSessionIdleTtlMs(), }; }; @@ -179,6 +181,23 @@ const resolveSandboxTimeoutMs = (): number | undefined => { return Math.floor(parsed); }; +// How long an MCP session may sit idle before the store evicts it. 0 disables +// eviction, which restores the old behaviour of holding every session for the +// lifetime of the process — only useful for diagnosing a client that cannot +// tolerate re-initializing. +const resolveMcpSessionIdleTtlMs = (): number | undefined => { + const raw = process.env.EXECUTOR_MCP_SESSION_IDLE_TTL_MS; + if (!raw) return undefined; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < 0) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a malformed operator knob + throw new Error( + `EXECUTOR_MCP_SESSION_IDLE_TTL_MS ${JSON.stringify(raw)} is not a non-negative number of milliseconds`, + ); + } + return Math.floor(parsed); +}; + // The org slug doubles as a URL segment (`//policies`), so an // operator-set value must fit the shared grammar and avoid reserved root // segments (api, mcp, login, …) — a colliding slug would shadow real routes. diff --git a/apps/host-selfhost/src/mcp/index.ts b/apps/host-selfhost/src/mcp/index.ts index a11d5cd8c..8a5fd2702 100644 --- a/apps/host-selfhost/src/mcp/index.ts +++ b/apps/host-selfhost/src/mcp/index.ts @@ -11,6 +11,7 @@ import type { import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth"; import type { SelfHostDbHandle } from "../db/self-host-db"; +import { loadConfig } from "../config"; import { selfHostMcpAuth } from "./auth"; import { makeSelfHostMcpSessionStore, @@ -142,7 +143,11 @@ export const makeSelfHostMcpSeams = ( webBaseUrl?: string, modernEnabled = true, ): SelfHostMcpSeams => { - const sessionStore = makeSelfHostMcpSessionStore(dbHandle, webBaseUrl); + const sessionStore = makeSelfHostMcpSessionStore( + dbHandle, + webBaseUrl, + loadConfig().mcpSessionIdleTtlMs, + ); const auth: Layer.Layer = selfHostMcpAuth.pipe( Layer.provide(Layer.succeed(BetterAuth)(betterAuth)), ); diff --git a/apps/host-selfhost/src/mcp/session-store.ts b/apps/host-selfhost/src/mcp/session-store.ts index 436671f3a..a31d987ab 100644 --- a/apps/host-selfhost/src/mcp/session-store.ts +++ b/apps/host-selfhost/src/mcp/session-store.ts @@ -36,6 +36,7 @@ export { McpEngineBuildError } from "@executor-js/host-mcp/in-memory-session-sto export const makeSelfHostMcpSessionStore = ( db: SelfHostDbHandle, webBaseUrl?: string, + sessionIdleTtlMs?: number, ): InMemoryMcpSessionStore => makeInMemoryMcpSessionStore( makeMcpBuildServer( @@ -48,7 +49,10 @@ export const makeSelfHostMcpSessionStore = ( selfHostAnalytics.record(`artifact_${action}`, { via: "agent" }), }, ), - { webBaseUrl }, + { + ...(webBaseUrl === undefined ? {} : { webBaseUrl }), + ...(sessionIdleTtlMs === undefined ? {} : { sessionIdleTtlMs }), + }, ); /** Build the stateless MCP server seam over the same self-host stack/config. */