Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-session-idle-eviction.md
Original file line number Diff line number Diff line change
@@ -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).
19 changes: 19 additions & 0 deletions apps/host-selfhost/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down Expand Up @@ -160,6 +161,7 @@ export const loadConfig = (): SelfHostConfig => {
organizationName: process.env.EXECUTOR_ORG_NAME ?? "Default",
orgSlug: resolveOrgSlug(),
sandboxTimeoutMs: resolveSandboxTimeoutMs(),
mcpSessionIdleTtlMs: resolveMcpSessionIdleTtlMs(),
};
};

Expand All @@ -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 (`/<slug>/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.
Expand Down
7 changes: 6 additions & 1 deletion apps/host-selfhost/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<McpAuthProvider, never, IdentityProvider> = selfHostMcpAuth.pipe(
Layer.provide(Layer.succeed(BetterAuth)(betterAuth)),
);
Expand Down
6 changes: 5 additions & 1 deletion apps/host-selfhost/src/mcp/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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. */
Expand Down
100 changes: 100 additions & 0 deletions packages/hosts/mcp/src/in-memory-session-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => "<html></html>",
}).pipe(Effect.map((mcpServer) => ({ mcpServer, engine }))),
{ sessionIdleTtlMs: 300 },
);

const open = async (): Promise<string> => {
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) =>
Expand Down
74 changes: 73 additions & 1 deletion packages/hosts/mcp/src/in-memory-session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -116,6 +133,11 @@ export interface InMemoryMcpSessionStore {
) => Promise<Response | null>;
/** 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<number>;
/** Dispose every live session — wire into the host's shutdown (not a seam). */
readonly close: () => Promise<void>;
}
Expand Down Expand Up @@ -172,14 +194,31 @@ 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<string, WebStandardStreamableHTTPServerTransport>();
const servers = new Map<string, McpServer>();
const owners = new Map<string, SessionOwner>();
const engines = new Map<string, ExecutionEngine<Cause.YieldableError>>();
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<string, number>();

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);
Expand All @@ -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);
};
Expand Down Expand Up @@ -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);
};

Expand Down Expand Up @@ -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 }),
});
Expand Down Expand Up @@ -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<number> => {
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<typeof setInterval> | 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 })));
},
Expand Down