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
3 changes: 3 additions & 0 deletions apps/cloud/src/env-augment.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ declare global {
MCP_RESOURCE_ORIGIN?: string;
MCP_SESSION_TIMEOUT_MS?: string;
MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS?: string;
/** Test-only override for the isolate-wide resident-runtime soft cap
* (see `RESIDENT_RUNTIME_SOFT_CAP`). Unset in production. */
MCP_RESIDENT_RUNTIME_SOFT_CAP?: string;
NODE_ENV?: string;

// Shared with frontend
Expand Down
23 changes: 23 additions & 0 deletions apps/cloud/src/mcp/session-durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ const positiveMilliseconds = (raw: string | undefined): number | undefined => {
return Math.floor(parsed);
};

/** Same shape as `positiveMilliseconds`, for a plain count rather than a
* duration — used only by the resident-runtime soft-cap override below. */
const positiveInteger = positiveMilliseconds;

type CloudSessionDbHandle = DbServiceShape & {
readonly sql: Sql;
readonly end: () => Promise<void>;
Expand Down Expand Up @@ -181,6 +185,25 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD
return mcpExecutionOwnerDirectoryFromNamespace(env.MCP_EXECUTION_OWNER);
}

// Test-only override so e2e can exercise a REAL cross-DO eviction request in
// workerd without registering 32 sessions. Unset in production, where this
// falls through to the base class's `RESIDENT_RUNTIME_SOFT_CAP`.
protected override residentRuntimeSoftCap(): number {
return positiveInteger(env.MCP_RESIDENT_RUNTIME_SOFT_CAP) ?? super.residentRuntimeSoftCap();
}

protected override supportsCapEviction(): boolean {
return true;
}

protected override requestSelfEviction(): Promise<void> {
// Routed through this session's OWN stub (never a direct in-process call)
// so `requestCapEviction`'s teardown runs under an IoContext scoped to
// this request, not whatever request happened to trigger the eviction
// check — see the base class's `requestSelfEviction` doc comment.
return mcpSessionStub(env.MCP_SESSION, this.sessionId).requestCapEviction();
}

protected override forwardModelResumeToOwner(
owner: McpExecutionOwnerRoute,
identity: McpApprovalOwner,
Expand Down
12 changes: 12 additions & 0 deletions apps/host-cloudflare/src/mcp/session-durable-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,18 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession
return mcpExecutionOwnerDirectoryFromNamespace(this.cfEnv.MCP_EXECUTION_OWNER);
}

protected override supportsCapEviction(): boolean {
return true;
}

protected override requestSelfEviction(): Promise<void> {
// Routed through this session's OWN stub (never a direct in-process call)
// so `requestCapEviction`'s teardown runs under an IoContext scoped to
// this request, not whatever request happened to trigger the eviction
// check — see the base class's `requestSelfEviction` doc comment.
return mcpSessionStub(this.cfEnv.MCP_SESSION, this.sessionId).requestCapEviction();
}

protected override forwardModelResumeToOwner(
owner: McpExecutionOwnerRoute,
identity: McpApprovalOwner,
Expand Down
256 changes: 256 additions & 0 deletions e2e/cloud/mcp-session-cap-eviction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
// Cloud: crossing the isolate's resident-runtime soft cap evicts an idle
// session's runtime through a REAL cross-Durable-Object request, not a
// same-context call.
//
// The defect this pins: the original design ran the evicted (candidate)
// session's teardown — closing its postgres.js socket, storage writes, span
// flush — directly inside the EVICTING session's own request/IoContext. In
// production workerd, I/O objects are bound to the IoContext that created
// them, so a cross-context call like that throws "Cannot perform I/O on
// behalf of a different request" or silently soft-fails. That failure mode
// cannot reproduce against an in-process unit-test double (same JS object,
// same context either way) — it only shows up against a real Durable Object
// stub. The fix routes eviction through the candidate's OWN stub
// (`requestCapEviction`, an RPC method mirroring `forwardModelResumeToOwner`),
// so the candidate's teardown runs in the candidate's own context, and this
// scenario is what actually exercises that stub in workerd.
//
// e2e/setup/resident-runtime-cap.ts lowers MCP_RESIDENT_RUNTIME_SOFT_CAP for
// the whole boot (see that file for the value and its headroom story), so
// this test can cross it with a bounded number of real sessions instead of
// registering the production default of 32.
import { expect } from "@effect/vitest";
import { Effect, Schedule } from "effect";

import { scenario } from "../src/scenario";
import { Mcp, Target, Telemetry } from "../src/services";
import type { Identity } from "../src/target";
import { E2E_MCP_RESIDENT_RUNTIME_SOFT_CAP } from "../setup/resident-runtime-cap";

const PROTOCOL_VERSION = "2025-03-26";
const JSON_AND_SSE = "application/json, text/event-stream";

// Comfortably past the cap: even if a handful of other scenarios' sessions
// are still incidentally resident when this file runs, enough of THESE
// sessions cross it that at least one eviction targets a session opened here.
const SESSIONS_TO_OPEN = E2E_MCP_RESIDENT_RUNTIME_SOFT_CAP + 10;

const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;

const mcpHeaders = (bearer: string, sessionId?: string) => ({
accept: JSON_AND_SSE,
authorization: `Bearer ${bearer}`,
"content-type": "application/json",
"mcp-protocol-version": PROTOCOL_VERSION,
...(sessionId ? { "mcp-session-id": sessionId } : {}),
});

const postJson = (mcpUrl: string, bearer: string, body: unknown, sessionId?: string) =>
fetch(mcpUrl, {
method: "POST",
headers: mcpHeaders(bearer, sessionId),
body: JSON.stringify(body),
});

/**
* Opens one fresh MCP session under an already-minted bearer. `initialize`
* without an existing `mcp-session-id` always mints a new session, the same
* way separate browser tabs sharing one login would — so many of these under
* one identity is a cheap way to grow the isolate's resident-runtime count
* without a full OAuth round trip per session.
*
* `recordSession` is called the moment the session id is known — before the
* `notifications/initialized` round trip below, not after this function
* returns. A session is live on the server as soon as `initialize` responds
* with an `mcp-session-id`, regardless of whether the handshake ever
* completes; recording it only on a full return left a failed notification
* (or an interrupt landing between the two requests) with no cleanup entry,
* orphaning a real session on the target isolate.
*/
const openSession = async (
mcpUrl: string,
bearer: string,
label: string,
recordSession: (sessionId: string) => void,
): Promise<string> => {
const initialized = await postJson(mcpUrl, bearer, {
jsonrpc: "2.0" as const,
id: "initialize",
method: "initialize",
params: {
protocolVersion: PROTOCOL_VERSION,
capabilities: {},
clientInfo: { name: `executor-e2e-cap-eviction-${label}`, version: "0.0.1" },
},
});
const sessionId = initialized.headers.get("mcp-session-id");
if (!sessionId) {
// oxlint-disable-next-line executor/no-error-constructor -- boundary: e2e setup precondition.
throw new Error(`openSession (${label}): no mcp-session-id header`);
}
// Recorded the moment the id exists — BEFORE the body read and status
// assertion below, either of which can throw with the session already live
// on the server. The cleanup finalizer needs the id on every one of those
// paths, not just a fully successful return.
recordSession(sessionId);
await initialized.text();
expect(initialized.status, `initialize (${label}) opens a session`).toBe(200);
const notification = await postJson(
mcpUrl,
bearer,
{ jsonrpc: "2.0" as const, method: "notifications/initialized" },
sessionId,
);
await notification.text();
expect(notification.status, `(${label}) completes the handshake`).toBe(202);
return sessionId;
};

const executeBody = (id: string, code: string) => ({
jsonrpc: "2.0" as const,
id,
method: "tools/call",
params: { name: "execute", arguments: { code } },
});

/** Run `execute` and return the response text once the call has fully settled. */
const execute = async (
mcpUrl: string,
bearer: string,
sessionId: string,
id: string,
code: string,
): Promise<string> => {
const response = await postJson(mcpUrl, bearer, executeBody(id, code), sessionId);
const body = await response.text();
expect(response.status, `execute ${id} is served`).toBe(200);
return body;
};

scenario(
"MCP session · crossing the resident-runtime cap evicts a session through a real cross-DO request",
{ timeout: 180_000 },
Effect.gen(function* () {
const target = yield* Target;
const mcp = yield* Mcp;
const telemetry = yield* Telemetry;

const identity = yield* target.newIdentity();
const bearer = yield* mcp.mintBearer(emailOf(identity));

// Opened sessions are recorded here as each one succeeds, so the cleanup
// below can close exactly what was actually opened even if the scenario
// fails partway through. Cap eviction already tears most of these down as
// a side effect of the scenario itself, but termination is idempotent
// (see mcp-destroyed-session-envelope.test.ts) — closing an already-torn-
// -down session is a harmless no-op, not a double-free.
const openedSessionIds: string[] = [];

const scenarioBody = Effect.gen(function* () {
// Open more sessions than the cap allows, at limited concurrency. None
// of them run any work, so every one is immediately eviction-eligible —
// crossing the cap must pick at least one and tear it down through its
// own stub.
const sessionIds = yield* Effect.forEach(
Array.from({ length: SESSIONS_TO_OPEN }, (_, index) => index),
(index) =>
Effect.promise(() =>
openSession(target.mcpUrl, bearer, `session-${index}`, (sessionId) => {
openedSessionIds.push(sessionId);
}),
),
{ concurrency: 8 },
);

expect(sessionIds.length, "every session opened").toBe(SESSIONS_TO_OPEN);
expect(new Set(sessionIds).size, "every session got a distinct id").toBe(SESSIONS_TO_OPEN);

// ---- a real cap eviction fired, against a session opened here -------
// Same span the idle path emits (`mcp.session.idle_runtime_dispose`);
// `mcp.session.dispose_reason` is what disambiguates the trigger.
const capDisposals = yield* telemetry
.searchSpans({ operation: "mcp.session.idle_runtime_dispose" })
.pipe(
Effect.map((spans) =>
spans.filter(
(span) =>
span.span.tags["mcp.session.dispose_reason"] === "cap" &&
sessionIds.some((id) => (span.span.tags["mcp.session.id"] ?? "").includes(id)),
),
),
Effect.filterOrFail(
(spans) => spans.length > 0,
() => "no cap-triggered idle_runtime_dispose span exported for any session opened here",
),
// The eviction request is fire-and-forget (`ctx.waitUntil`) from the
// evictor's `init`, and its own span flush is off that same
// background path — same polling grace the idle-disposal scenario
// uses for its alarm-driven flush.
Effect.retry(Schedule.both(Schedule.spaced("500 millis"), Schedule.recurs(40))),
);

expect(
capDisposals.length,
"crossing the resident-runtime cap evicted at least one session opened here",
).toBeGreaterThan(0);

const disposal = capDisposals[0]!;
expect(
disposal.span.tags["mcp.isolate.resident_runtimes"],
"the cap disposal records the isolate's resident-runtime gauge, same as the idle path",
).toBeDefined();

// ---- the evicted session still works — restore is transparent -------
const evictedSessionId = sessionIds.find((id) =>
(disposal.span.tags["mcp.session.id"] ?? "").includes(id),
);
expect(
evictedSessionId,
"the disposed span's session id matches a session opened here",
).toBeDefined();

const marker = `after-cap-evict-${evictedSessionId}`;
const restored = yield* Effect.promise(() =>
execute(
target.mcpUrl,
bearer,
evictedSessionId!,
"execute-after-cap-eviction",
`return ${JSON.stringify(marker)};`,
),
);
expect(
restored,
"the evicted session serves the next call correctly after restoring underneath the client",
).toContain(marker);
});

yield* scenarioBody.pipe(
// `Effect.ensuring`, not a trailing statement: a failure partway through
// (an assertion above, a timed-out span search) must not leak the
// sessions already opened. Read `openedSessionIds` at cleanup time, not
// capture time — `Effect.suspend` so the array is read when the
// finalizer actually runs, after the scenario body has finished pushing
// to it, rather than snapshotted empty at construction.
Effect.ensuring(
Effect.suspend(() =>
Effect.forEach(
openedSessionIds,
(sessionId) =>
Effect.tryPromise(async () => {
const closed = await fetch(target.mcpUrl, {
method: "DELETE",
headers: {
authorization: `Bearer ${bearer}`,
"mcp-session-id": sessionId,
},
});
await closed.text();
}).pipe(Effect.ignore),
{ concurrency: 8, discard: true },
),
),
),
);
}),
);
5 changes: 5 additions & 0 deletions e2e/setup/cloud.boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
E2E_EXECUTION_RATE_LIMIT,
E2E_EXECUTION_RATE_LIMIT_CHECK_TIMEOUT_MS,
} from "./execution-limits";
import { E2E_MCP_RESIDENT_RUNTIME_SOFT_CAP } from "./resident-runtime-cap";

export const cloudDir = fileURLToPath(new URL("../../apps/cloud/", import.meta.url));

Expand Down Expand Up @@ -107,6 +108,10 @@ export const bootCloud = async (options: CloudBootOptions): Promise<CloudBooted>
MCP_RESOURCE_ORIGIN: options.publicUrl,
MCP_SESSION_TIMEOUT_MS: process.env.MCP_SESSION_TIMEOUT_MS,
MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: process.env.MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS,
// See resident-runtime-cap.ts for why this value, and why it is safe to
// set unconditionally for the whole boot (same treatment as the execution
// rate limit below).
MCP_RESIDENT_RUNTIME_SOFT_CAP: String(E2E_MCP_RESIDENT_RUNTIME_SOFT_CAP),
ALLOW_LOCAL_NETWORK: "true",
// A first-party GitHub app for the first-party-oauth scenario: proves the
// env → HostConfig → executor plumbing end to end. The scenario asserts the
Expand Down
21 changes: 21 additions & 0 deletions e2e/setup/resident-runtime-cap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// The e2e worker's isolate-wide resident-runtime soft cap
// (MCP_RESIDENT_RUNTIME_SOFT_CAP). One constant with two consumers, the boot
// recipe env (cloud.boot.ts) and the cap-eviction scenario
// (cloud/mcp-session-cap-eviction.test.ts), so they cannot drift apart.
//
// Same squeeze as EXECUTION_RATE_LIMIT_PER_HOUR (execution-limits.ts). It must
// be LOW enough that the cap-eviction scenario can cross it with a bounded
// number of real sessions opened under one identity (prod's 32 is reachable
// but wasteful to open on every run), and HIGH enough that no OTHER cloud
// scenario's incidental concurrent MCP-session count trips it: this is an
// isolate-wide counter, not per-org, and the dev server is shared across the
// whole cloud e2e run (`fileParallelism: false` keeps files serial, but a
// single busy file can still open a double-digit number of sessions before
// any of them idle out). The busiest current file (mcp-protocol.test.ts) opens
// on the order of a dozen sessions across its scenarios; this stays well above
// that with headroom. If a scenario ever incidentally trips a cap eviction (a
// span with `mcp.session.dispose_reason: "cap"` for a session it didn't
// expect), that is not a correctness bug — eviction is designed to be
// transparent, restoring on the next call — but it means this constant needs
// to grow: raise it here, never by special-casing a scenario against it.
export const E2E_MCP_RESIDENT_RUNTIME_SOFT_CAP = 24;
Loading
Loading