Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/mcp-session-idle-eviction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@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). An open server-to-client stream does not defer eviction, matching how cloud's session alarm destroys a session once it passes its running-lease ceiling; an evicted id answers 404 `-32001`, which is the client's cue to re-initialize.

A request in flight holds its session: idleness counts from when a call ends, not from when it started, so a tool call slower than the idle window is never cut off mid-flight. Disposal also shuts the session's execution engine down rather than only dropping the reference, which is what ends its detached sandbox fibers, and a handle that fails to close is now logged with its session id instead of being discarded silently.
4 changes: 1 addition & 3 deletions apps/host-selfhost/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,7 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => {
const { identityLayer, authHandler, betterAuth } = await resolveAuthProviders(dbHandle);

// ---- the in-process MCP serving seams (+ shutdown hook) ----------------
// Pass the pinned public origin so browser-approval URLs are reachable behind
// a reverse proxy (not the internal 127.0.0.1 bind from the request URL).
const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config.webBaseUrl);
const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config);

// CLI device-login discovery (`executor login`). Points the CLI at Better
// Auth's device endpoints; `requestFormat: "json"` because those endpoints
Expand Down
23 changes: 23 additions & 0 deletions apps/host-selfhost/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ export interface SelfHostConfig {
* minutes (the same pattern as MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS on cloud).
*/
readonly sandboxTimeoutMs: number | undefined;
/**
* How long an MCP session may sit idle before the in-process store evicts it,
* or undefined for the store's own default (30 minutes). 0 disables eviction.
*/
readonly mcpSessionIdleTtlMs: number | undefined;
/**
* How long a connection's persisted remote tool catalog stays fresh, in ms.
* `undefined` takes the SDK default (15 minutes); `null` disables time-based
Expand Down Expand Up @@ -163,6 +168,7 @@ export const loadConfig = (): SelfHostConfig => {
organizationName: process.env.EXECUTOR_ORG_NAME ?? "Default",
orgSlug: resolveOrgSlug(),
sandboxTimeoutMs: resolveSandboxTimeoutMs(),
mcpSessionIdleTtlMs: resolveMcpSessionIdleTtlMs(),
toolsSyncTtlMs: resolveToolsSyncTtlMs(),
};
};
Expand All @@ -183,6 +189,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
16 changes: 14 additions & 2 deletions apps/host-selfhost/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {

import { BetterAuth, type BetterAuthHandle } from "../auth/better-auth";
import type { SelfHostDbHandle } from "../db/self-host-db";
import type { SelfHostConfig } from "../config";
import { selfHostMcpAuth } from "./auth";
import {
makeSelfHostMcpSessionStore,
Expand Down Expand Up @@ -128,13 +129,24 @@ const makeApprovalHandler =
* instance provided; it still requires `IdentityProvider` from the resolved
* identity seam. Returns the three seam Layers plus the `close()` lifetime hook
* the app wires into shutdown.
*
* Takes the already-resolved `SelfHostConfig` rather than reading it here: the
* app loads it once at boot, and `loadConfig()` refuses to boot on a malformed
* operator knob, so calling it from a seam factory would both hide an env read
* behind construction and move that failure off the boot path.
*/
export const makeSelfHostMcpSeams = (
dbHandle: SelfHostDbHandle,
betterAuth: BetterAuthHandle,
webBaseUrl?: string,
config: SelfHostConfig,
): SelfHostMcpSeams => {
const sessionStore = makeSelfHostMcpSessionStore(dbHandle, webBaseUrl);
// The pinned public origin keeps browser-approval URLs reachable behind a
// reverse proxy (not the internal 127.0.0.1 bind from the request URL).
const sessionStore = makeSelfHostMcpSessionStore(
dbHandle,
config.webBaseUrl,
config.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 }),
},
);

/** The `McpSessionStore` envelope seam over a freshly built in-process store. */
Expand Down
18 changes: 16 additions & 2 deletions apps/host-selfhost/src/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ const selfHostHttpMiddleware = (betterAuth: BetterAuthHandle) =>

export const startServer = async (): Promise<void> => {
const config = loadConfig();
const { AppLayer, betterAuth } = await makeSelfHostApp();
const { AppLayer, betterAuth, closeDb } = await makeSelfHostApp();

// Serve the built SPA, split by cacheability so a redeploy is picked up at
// once instead of stranding browsers on a stale shell:
Expand Down Expand Up @@ -141,6 +141,16 @@ export const startServer = async (): Promise<void> => {
Effect.addFinalizer(() => Effect.promise(() => disposeAnalytics())),
);

// Server-scope finalizer: release what the app opened at boot — every live
// MCP session (transport, server, and its execution engine, whose detached
// sandbox fibers keep querying the DB until the engine is shut down) and then
// the shared libSQL handle itself. `makeSelfHostApiHandler` runs this in its
// `dispose`, so tests have always released it; the long-lived server dropped
// the hook on the floor and leaked both across a graceful shutdown.
const AppResourcesLive = Layer.effectDiscard(
Effect.addFinalizer(() => Effect.promise(() => closeDb())),
);

// OTLP export, or `Layer.empty` when no collector is configured (see
// ./telemetry). The `http.server` envelope span each request's `withSpan`
// children parent under is NOT wired here: `HttpEffect.toHandled` already
Expand All @@ -162,7 +172,11 @@ export const startServer = async (): Promise<void> => {
// in scope while the app's layers build, or spans created during construction
// resolve the default no-op tracer and are silently dropped.
await BunRuntime.runMain(
Layer.launch(Layer.merge(ServerLive, AnalyticsFlushLive).pipe(Layer.provide(TelemetryLive))),
Layer.launch(
Layer.mergeAll(ServerLive, AnalyticsFlushLive, AppResourcesLive).pipe(
Layer.provide(TelemetryLive),
),
),
);
};

Expand Down
172 changes: 172 additions & 0 deletions e2e/selfhost/mcp-session-idle-eviction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// Selfhost-only: an MCP session that goes idle past the store's TTL is
// reclaimed, and the next request on that session id gets the 404 / -32001 cue
// that tells a client to re-initialize.
//
// Why this scenario boots its OWN instance instead of using the shared one:
// the idle window is a BOOT-TIME operator knob
// (EXECUTOR_MCP_SESSION_IDLE_TTL_MS), so there is no way to shrink it on a
// running server. Waiting out the 30-minute default is not an option, and
// shrinking it on the SHARED instance would silently evict sessions underneath
// every other selfhost scenario. A dedicated instance on its own port and data
// dir keeps the short window contained to this file.
//
// Why the wait is a single silent sleep rather than a poll loop: every request
// the store forwards restamps that session's last-seen time. A poll loop is
// itself the traffic that keeps the session alive, so it could never observe an
// eviction. The scenario stays completely silent for one window, then makes
// exactly one request.
//
// Auth is the Better Auth session cookie, not an OAuth bearer: self-host's MCP
// auth provider accepts the cookie/api-key identity path, and the credential is
// not what is under test here — session lifetime is.
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { expect } from "@effect/vitest";
import { Effect } from "effect";

import { scenario } from "../src/scenario";
import { RunDir, Target } from "../src/services";
import { claimAndBoot } from "../src/ports";
import { isBootReadinessTimeout } from "../setup/boot";
import { bootSelfhost } from "../setup/selfhost.boot";
import { SELFHOST_ADMIN, signInSession } from "../targets/selfhost";

/** The idle window this instance runs with — small enough that the sweep, not
* the TTL, sets the pace. */
const IDLE_TTL_MS = 2_000;

/**
* How long to leave the session untouched. The store floors its sweep interval
* at 30s, so an idle session is disposed at the first tick that falls at least
* one TTL after its last request. Waiting two full sweep intervals plus the TTL
* means the assertion can never race a tick that has not fired yet.
*/
const QUIET_WINDOW_MS = 2 * 30_000 + IDLE_TTL_MS + 5_000;

interface JsonRpcErrorBody {
readonly error?: { readonly code?: number; readonly message?: string };
}

scenario(
"MCP · an idle self-host session is evicted and answers 404 -32001 until the client re-initializes",
// Own vite dev boot (cold on a fresh checkout) plus a 67s quiet window, so
// this needs materially more than the project's 180s default.
{ timeout: 420_000 },
Effect.gen(function* () {
// Selfhost-shaped scenario: yielded for the target name in failures, and so
// the file reads like its neighbours.
yield* Target;
const runDir = yield* RunDir;

const dataDir = mkdtempSync(join(tmpdir(), "executor-selfhost-idle-ttl-"));

// A distinct env var (not E2E_SELFHOST_PORT, which the shared instance has
// already published into this worker's env) so the claim actually probes
// and locks a free port instead of returning the shared one.
const booted = yield* Effect.promise(() =>
claimAndBoot(
[{ envVar: "E2E_SELFHOST_IDLE_TTL_PORT", offset: 6, label: "selfhost idle-ttl vite dev" }],
async (ports) => {
const port = ports.E2E_SELFHOST_IDLE_TTL_PORT!;
const baseUrl = `http://localhost:${port}`;
const procs = await bootSelfhost({
port,
webBaseUrl: baseUrl,
admin: SELFHOST_ADMIN,
dataDir,
logFile: join(runDir, "idle-ttl-boot.log"),
mcpSessionIdleTtlMs: IDLE_TTL_MS,
});
return { teardown: procs.teardown, value: baseUrl };
},
{ label: "selfhost idle-ttl", retryWhen: isBootReadinessTimeout },
),
);

yield* Effect.gen(function* () {
const baseUrl = booted.value;
const mcpUrl = new URL("/mcp", baseUrl).toString();
const { cookieHeader } = yield* Effect.promise(() => signInSession(baseUrl, SELFHOST_ADMIN));

const initialize = async (): Promise<Response> =>
fetch(mcpUrl, {
method: "POST",
headers: {
cookie: cookieHeader,
"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-ttl-e2e", version: "1" },
},
}),
});

const listTools = async (sessionId: string, id: number): Promise<Response> =>
fetch(mcpUrl, {
method: "POST",
headers: {
cookie: cookieHeader,
"mcp-session-id": sessionId,
"mcp-protocol-version": "2025-06-18",
"content-type": "application/json",
accept: "application/json, text/event-stream",
},
body: JSON.stringify({ jsonrpc: "2.0", id, method: "tools/list" }),
});

// 1. A client initializes and the session serves.
const opened = yield* Effect.promise(initialize);
expect(opened.status, "initialize succeeds").toBe(200);
const sessionId = opened.headers.get("mcp-session-id");
expect(sessionId, "initialize returns a session id").toEqual(expect.any(String));

const working = yield* Effect.promise(() => listTools(sessionId!, 2));
expect(working.status, "the fresh session serves a request").toBe(200);

// 2. Idleness, driven by the clock and nothing else. Touching the session
// here — even to poll — would restamp it and defeat the measurement.
yield* Effect.sleep(`${QUIET_WINDOW_MS} millis`);

// 3. The evicted id is gone, and says so in the shape a client acts on:
// 404 tells it the id is dead, -32001 is the session-lifecycle code.
const afterIdle = yield* Effect.promise(() => listTools(sessionId!, 3));
const afterIdleBody = yield* Effect.promise(() => afterIdle.text());
// A session that was NOT evicted answers with the whole tool catalog, so
// the diagnostic is truncated — the status is the assertion, and a full
// tools/list dump in the failure output helps nobody.
expect(
afterIdle.status,
`an idle session is evicted, so its id 404s; body starts: ${afterIdleBody.slice(0, 200)}`,
).toBe(404);
// oxlint-disable-next-line executor/no-json-parse -- boundary: the raw JSON-RPC error frame this scenario asserts on, never decoded into a domain type
const parsed = JSON.parse(afterIdleBody) as JsonRpcErrorBody;
expect(parsed.error?.code, "the 404 carries the session-lifecycle code").toBe(-32001);

// 4. The cue is actionable: re-initializing gets a NEW, working session.
const reopened = yield* Effect.promise(initialize);
expect(reopened.status, "the client can re-initialize after eviction").toBe(200);
const newSessionId = reopened.headers.get("mcp-session-id");
expect(newSessionId, "re-initialize returns a session id").toEqual(expect.any(String));
expect(newSessionId, "re-initialize issues a different session").not.toBe(sessionId);

const afterReinit = yield* Effect.promise(() => listTools(newSessionId!, 4));
expect(afterReinit.status, "the re-initialized session serves a request").toBe(200);
}).pipe(
Effect.ensuring(
Effect.promise(async () => {
await booted.teardown();
rmSync(dataDir, { recursive: true, force: true });
}),
),
);
}),
);
7 changes: 7 additions & 0 deletions e2e/setup/selfhost.boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ export interface SelfhostBootOptions {
/** Shrink the sandbox execution budget (EXECUTOR_SANDBOX_TIMEOUT_MS) so
* deadline scenarios prove their race in seconds. Omit for production. */
readonly sandboxTimeoutMs?: number;
/** Shrink the MCP session idle window (EXECUTOR_MCP_SESSION_IDLE_TTL_MS) so
* the eviction scenario proves in seconds what otherwise takes 30 minutes.
* Omit for production; the app then uses the store's own default. */
readonly mcpSessionIdleTtlMs?: number;
}

export const bootSelfhost = async (options: SelfhostBootOptions): Promise<BootedProcesses> => {
Expand Down Expand Up @@ -57,6 +61,9 @@ export const bootSelfhost = async (options: SelfhostBootOptions): Promise<Booted
...(options.sandboxTimeoutMs !== undefined
? { EXECUTOR_SANDBOX_TIMEOUT_MS: String(options.sandboxTimeoutMs) }
: {}),
...(options.mcpSessionIdleTtlMs !== undefined
? { EXECUTOR_MCP_SESSION_IDLE_TTL_MS: String(options.mcpSessionIdleTtlMs) }
: {}),
},
logFile: options.logFile,
},
Expand Down
Loading
Loading