Skip to content

Commit 2ea7494

Browse files
authored
Negative-cache dead MCP session ids in the workers (#1627)
1 parent d3f0617 commit 2ea7494

8 files changed

Lines changed: 571 additions & 86 deletions

File tree

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { beforeEach, describe, expect, it, vi } from "@effect/vitest";
2+
import { Effect, Layer } from "effect";
3+
4+
import {
5+
authenticated,
6+
McpAuthProvider,
7+
unauthorized,
8+
type Principal,
9+
} from "@executor-js/host-mcp";
10+
11+
import { cloudDeadSessionCacheForTest, makeCloudMcpAgentHandler } from "./agent-handler";
12+
13+
const principal: Principal = {
14+
accountId: "acct_test",
15+
organizationId: "org_test",
16+
organizationName: "Test Org",
17+
email: "test@example.com",
18+
name: "Test",
19+
avatarUrl: null,
20+
roles: ["member"],
21+
};
22+
23+
const AuthProviderLive = Layer.succeed(McpAuthProvider)({
24+
discoveryRoutes: [],
25+
resourceMetadataUrl: (request) => new URL("/.well-known/mcp", request.url).toString(),
26+
authenticate: (request) =>
27+
Effect.succeed(
28+
request.headers.has("authorization") ? authenticated(principal) : unauthorized(),
29+
),
30+
});
31+
32+
const requestFor = (method: "GET" | "POST", sessionId: string, authenticated = true): Request =>
33+
new Request("https://executor.test/mcp", {
34+
method,
35+
headers: {
36+
...(authenticated ? { authorization: "Bearer test" } : {}),
37+
"mcp-session-id": sessionId,
38+
...(method === "POST" ? { "content-type": "application/json" } : {}),
39+
},
40+
...(method === "POST"
41+
? {
42+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),
43+
}
44+
: {}),
45+
});
46+
47+
const makeHarness = (owner: "not_found" | "terminated" = "not_found") => {
48+
const ownerChecks = { count: 0 };
49+
const traces = { count: 0 };
50+
const stub = {
51+
validateMcpSessionOwner: async () => {
52+
ownerChecks.count += 1;
53+
return owner;
54+
},
55+
};
56+
const namespace = {
57+
idFromString: (sessionId: string) => sessionId,
58+
get: () => stub,
59+
};
60+
// oxlint-disable-next-line executor/no-double-cast -- test boundary: the handler only reads the MCP_SESSION namespace in these legacy dead-session cases
61+
const env = { MCP_SESSION: namespace } as unknown as Env;
62+
// oxlint-disable-next-line executor/no-double-cast -- test boundary: no ExecutionContext capability is used before the dead-session response
63+
const ctx = {} as unknown as ExecutionContext;
64+
const handler = makeCloudMcpAgentHandler({
65+
authProvider: AuthProviderLive,
66+
makeModernServerBuilder: () => ({ build: () => Effect.die("unused modern builder") }),
67+
traceRequest: async (request, _env, _ctx, handle) => {
68+
traces.count += 1;
69+
return handle(request);
70+
},
71+
});
72+
return { ctx, env, handler, ownerChecks, traces };
73+
};
74+
75+
describe("cloud MCP dead-session negative cache", () => {
76+
beforeEach(() => {
77+
vi.useRealTimers();
78+
cloudDeadSessionCacheForTest.clear();
79+
});
80+
81+
it.each([
82+
{ method: "GET" as const, status: 405 },
83+
{ method: "POST" as const, status: 404 },
84+
])("serves a repeated $method without another DO lookup", async ({ method, status }) => {
85+
const { ctx, env, handler, ownerChecks, traces } = makeHarness();
86+
const sessionId = `dead-${method.toLowerCase()}`;
87+
88+
const first = await handler(requestFor(method, sessionId), env, ctx);
89+
const second = await handler(requestFor(method, sessionId), env, ctx);
90+
91+
expect(first.status).toBe(status);
92+
expect(second.status).toBe(status);
93+
expect(ownerChecks.count).toBe(1);
94+
expect(traces.count).toBe(1);
95+
});
96+
97+
it("keeps authentication ahead of cached session existence", async () => {
98+
const { ctx, env, handler, ownerChecks, traces } = makeHarness();
99+
const sessionId = "dead-auth";
100+
101+
expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405);
102+
const unauthenticated = await handler(requestFor("GET", sessionId, false), env, ctx);
103+
104+
expect(unauthenticated.status).toBe(401);
105+
expect(ownerChecks.count).toBe(1);
106+
expect(traces.count).toBe(1);
107+
});
108+
109+
it("preserves the terminated-session response on a cached hit", async () => {
110+
const { ctx, env, handler, ownerChecks, traces } = makeHarness("terminated");
111+
const sessionId = "dead-terminated";
112+
113+
const first = await handler(requestFor("POST", sessionId), env, ctx);
114+
const second = await handler(requestFor("POST", sessionId), env, ctx);
115+
116+
expect(first.status).toBe(404);
117+
expect(second.status).toBe(404);
118+
expect(await second.text()).toBe(await first.text());
119+
expect(ownerChecks.count).toBe(1);
120+
expect(traces.count).toBe(1);
121+
});
122+
123+
it("consults the DO again after five minutes", async () => {
124+
vi.useFakeTimers();
125+
vi.setSystemTime(0);
126+
const { ctx, env, handler, ownerChecks } = makeHarness();
127+
const sessionId = "dead-expiry";
128+
129+
expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405);
130+
vi.advanceTimersByTime(5 * 60 * 1_000 + 1);
131+
expect((await handler(requestFor("GET", sessionId), env, ctx)).status).toBe(405);
132+
133+
expect(ownerChecks.count).toBe(2);
134+
});
135+
136+
it("evicts the oldest entry without exceeding 4,096 sessions", () => {
137+
for (let index = 0; index <= 4_096; index += 1) {
138+
cloudDeadSessionCacheForTest.remember(`dead-${index}`, 0);
139+
}
140+
141+
expect(cloudDeadSessionCacheForTest.size()).toBe(4_096);
142+
expect(cloudDeadSessionCacheForTest.has("dead-0", 1)).toBe(false);
143+
expect(cloudDeadSessionCacheForTest.has("dead-1", 1)).toBe(true);
144+
expect(cloudDeadSessionCacheForTest.has("dead-4096", 1)).toBe(true);
145+
});
146+
});

apps/cloud/src/mcp/agent-handler.ts

Lines changed: 91 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as OtelTracer from "@effect/opentelemetry/Tracer";
2-
import { Effect, Predicate } from "effect";
2+
import { Effect, Layer, Predicate } from "effect";
33

44
import {
55
McpAuthProvider,
@@ -8,6 +8,7 @@ import {
88
defaultMcpResource,
99
UNAVAILABLE_RETRY_AFTER_SECONDS,
1010
type AuthOutcome,
11+
type McpModernServerBuilder,
1112
type McpResource,
1213
} from "@executor-js/host-mcp";
1314
import { requestBodyFromRequest } from "@executor-js/host-mcp/tool-server";
@@ -32,9 +33,53 @@ import { createMcpSessionStub, mcpSessionStub } from "@executor-js/cloudflare/mc
3233
import { wrapMcpSseResponse } from "../observability/memory-metrics";
3334
import { WorkerTelemetryLive } from "../observability/telemetry";
3435
import { cloudMcpAuth } from "./auth-provider";
35-
import { makeCloudModernMcpServerBuilder } from "./session-durable-object";
3636
import { parseTraceparent } from "./traceparent";
3737

38+
const DEAD_SESSION_CACHE_TTL_MS = 5 * 60 * 1_000;
39+
const DEAD_SESSION_CACHE_MAX_ENTRIES = 4_096;
40+
const deadSessionExpiries = new Map<string, number>();
41+
const timedOutSessionIds = new Set<string>();
42+
43+
type DeadSessionReason = "not_found" | "timed_out";
44+
45+
const isDeadSessionCached = (sessionId: string, now = Date.now()): boolean => {
46+
const expiry = deadSessionExpiries.get(sessionId);
47+
if (expiry === undefined) return false;
48+
if (expiry > now) return true;
49+
deadSessionExpiries.delete(sessionId);
50+
timedOutSessionIds.delete(sessionId);
51+
return false;
52+
};
53+
54+
const cacheDeadSession = (sessionId: string, reason: DeadSessionReason, now = Date.now()): void => {
55+
deadSessionExpiries.delete(sessionId);
56+
if (deadSessionExpiries.size >= DEAD_SESSION_CACHE_MAX_ENTRIES) {
57+
const oldestSessionId = deadSessionExpiries.keys().next().value;
58+
if (oldestSessionId !== undefined) {
59+
deadSessionExpiries.delete(oldestSessionId);
60+
timedOutSessionIds.delete(oldestSessionId);
61+
}
62+
}
63+
deadSessionExpiries.set(sessionId, now + DEAD_SESSION_CACHE_TTL_MS);
64+
if (reason === "timed_out") timedOutSessionIds.add(sessionId);
65+
else timedOutSessionIds.delete(sessionId);
66+
};
67+
68+
const cachedDeadSessionMessage = (sessionId: string): string =>
69+
timedOutSessionIds.has(sessionId) ? "Session timed out, please reconnect" : "Session not found";
70+
71+
/** Test-only access to reset and verify the isolate-local dead-session cache. */
72+
export const cloudDeadSessionCacheForTest = {
73+
clear: (): void => {
74+
deadSessionExpiries.clear();
75+
timedOutSessionIds.clear();
76+
},
77+
remember: (sessionId: string, now?: number): void =>
78+
cacheDeadSession(sessionId, "not_found", now),
79+
has: isDeadSessionCached,
80+
size: (): number => deadSessionExpiries.size,
81+
};
82+
3883
const jsonRpcResponse = (
3984
status: number,
4085
code: number,
@@ -97,12 +142,12 @@ const renderAuthError = (
97142
});
98143
};
99144

100-
const authenticate = (request: Request) =>
145+
const authenticate = (request: Request, authProvider: Layer.Layer<McpAuthProvider>) =>
101146
Effect.gen(function* () {
102147
const auth = yield* McpAuthProvider;
103148
const outcome = yield* auth.authenticate(request);
104149
return { auth, outcome };
105-
}).pipe(Effect.provide(cloudMcpAuth));
150+
}).pipe(Effect.provide(authProvider));
106151

107152
// The earlier shared envelope ran the MCP auth path inside the Effect app, whose
108153
// HttpMiddleware provided the OTEL tracer — that is where the `mcp.request`
@@ -126,6 +171,21 @@ const runTraced = <A>(request: Request, program: Effect.Effect<A>): Promise<A> =
126171
);
127172
};
128173

174+
type TraceCloudMcpRequest = (
175+
request: Request,
176+
env: Env,
177+
ctx: ExecutionContext,
178+
handle: (tracedRequest: Request) => Promise<Response>,
179+
) => Promise<Response>;
180+
181+
interface CloudMcpAgentHandlerOptions {
182+
readonly makeModernServerBuilder: (
183+
session: McpSessionProps["session"],
184+
) => McpModernServerBuilder["Service"];
185+
readonly authProvider?: Layer.Layer<McpAuthProvider>;
186+
readonly traceRequest?: TraceCloudMcpRequest;
187+
}
188+
129189
// The MCP resource the request targets. `server.ts` routes both the bare `/mcp`
130190
// and `/mcp/toolkits/<slug>` to this handler (`prepareMcpOrgScope` strips the org
131191
// selector but keeps the toolkit segment), so a session minted on a toolkit path
@@ -158,11 +218,14 @@ const propsForPrincipal = (
158218
};
159219
});
160220

161-
export const makeCloudMcpAgentHandler = () => {
221+
/** Build the cloud worker's authenticated legacy/modern MCP request handler. */
222+
export const makeCloudMcpAgentHandler = (options: CloudMcpAgentHandlerOptions) => {
223+
const authProvider = options.authProvider ?? cloudMcpAuth;
224+
const traceRequest = options.traceRequest ?? ((request, _env, _ctx, handle) => handle(request));
162225
const modern = makeMcpModernRequestRouter();
163226
const ALLOWED_METHODS = new Set(["GET", "POST", "DELETE", "OPTIONS"]);
164227

165-
return async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => {
228+
const handle = async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => {
166229
if (request.method === "OPTIONS") {
167230
return mcpCorsPreflightResponse(request.headers.get("access-control-request-headers"));
168231
}
@@ -173,7 +236,7 @@ export const makeCloudMcpAgentHandler = () => {
173236
}
174237
const sessionId = request.headers.get("mcp-session-id");
175238

176-
const { auth, outcome } = await runTraced(request, authenticate(request));
239+
const { auth, outcome } = await runTraced(request, authenticate(request, authProvider));
177240
if (!Predicate.isTagged(outcome, "Authenticated")) {
178241
// Destroying a live session on auth grounds requires a POSITIVE
179242
// determination that access is genuinely gone — only `Forbidden` carries
@@ -218,7 +281,7 @@ export const makeCloudMcpAgentHandler = () => {
218281
resource,
219282
props,
220283
requestStateSigningKey: requireMcpRequestStateKey(env.MCP_REQUEST_STATE_KEY),
221-
builder: makeCloudModernMcpServerBuilder(props.session),
284+
builder: options.makeModernServerBuilder(props.session),
222285
sessions: env.MCP_SESSION,
223286
executionOwners: mcpExecutionOwnerDirectoryFromNamespace(env.MCP_EXECUTION_OWNER),
224287
});
@@ -238,18 +301,20 @@ export const makeCloudMcpAgentHandler = () => {
238301
if (sessionId && !existingSession) {
239302
return deadSessionResponse(request.method, "Session not found");
240303
}
241-
if (existingSession) {
304+
if (existingSession && sessionId) {
242305
const owner = await existingSession.validateMcpSessionOwner({
243306
accountId: outcome.principal.accountId,
244307
organizationId: outcome.principal.organizationId,
245308
});
246309
if (owner === "not_found") {
310+
cacheDeadSession(sessionId, "not_found");
247311
return deadSessionResponse(request.method, "Session not found");
248312
}
249313
if (owner === "terminated") {
250314
// DELETE-condemned but the deferred destroy alarm hasn't wiped storage
251315
// yet. Same envelope as the post-destroy race below: the client must
252316
// treat the id as dead and reconnect.
317+
cacheDeadSession(sessionId, "timed_out");
253318
return deadSessionResponse(request.method, "Session timed out, please reconnect");
254319
}
255320
if (owner === "forbidden") {
@@ -286,11 +351,28 @@ export const makeCloudMcpAgentHandler = () => {
286351
// client to be told to reconnect, matching a timed-out session).
287352
// oxlint-disable-next-line executor/no-unknown-error-message -- adapter boundary: the abort reason is a plain runtime Error whose message IS the signal
288353
if (Predicate.isError(error) && error.message === "destroyed") {
354+
if (sessionId) cacheDeadSession(sessionId, "timed_out");
289355
return deadSessionResponse(request.method, "Session timed out, please reconnect");
290356
}
291357
// oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: rethrow anything that isn't the condemned-DO abort to the Workers runtime unchanged
292358
throw error;
293359
}
294360
return withMcpResponseHeaders(wrapMcpSseResponse(request, env, response));
295361
};
362+
363+
return async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => {
364+
const sessionId = request.headers.get("mcp-session-id");
365+
const cacheEligible = request.method !== "OPTIONS" && ALLOWED_METHODS.has(request.method);
366+
if (cacheEligible && sessionId && isDeadSessionCached(sessionId)) {
367+
return Effect.runPromise(
368+
Effect.gen(function* () {
369+
const { auth, outcome } = yield* authenticate(request, authProvider);
370+
return Predicate.isTagged(outcome, "Authenticated")
371+
? deadSessionResponse(request.method, cachedDeadSessionMessage(sessionId))
372+
: renderAuthError(auth, request, outcome);
373+
}).pipe(Effect.withTracerEnabled(false)),
374+
);
375+
}
376+
return traceRequest(request, env, ctx, (tracedRequest) => handle(tracedRequest, env, ctx));
377+
};
296378
};

0 commit comments

Comments
 (0)