Skip to content

Commit 9f7f6f0

Browse files
authored
Fix MCP idle session restore (#818)
1 parent 1d413ce commit 9f7f6f0

2 files changed

Lines changed: 52 additions & 12 deletions

File tree

apps/cloud/src/mcp-flow.test.ts

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,44 @@ describe("/mcp session restore", () => {
417417
expect(body.result?.tools?.some((tool) => tool.name === "execute")).toBe(true);
418418
}, 15_000);
419419

420+
it("restores an initialized session after the idle alarm suspends the runtime", async () => {
421+
const orgId = nextOrgId();
422+
const accountId = nextAccountId();
423+
const bearer = makeTestBearer(accountId, orgId);
424+
await seedOrg(orgId);
425+
426+
const initializeResponse = await mcpPost({
427+
bearer,
428+
body: INITIALIZE_REQUEST,
429+
});
430+
expect(initializeResponse.status).toBe(200);
431+
const sessionId = initializeResponse.headers.get("mcp-session-id");
432+
expect(sessionId).toBeTruthy();
433+
434+
const ns = env.MCP_SESSION;
435+
const stub = ns.get(ns.idFromString(sessionId!));
436+
await runInDurableObject(stub, async (instance, state) => {
437+
doActivityState(instance).lastActivityMs = 0;
438+
await state.storage.put(LAST_ACTIVITY_KEY, Date.now() - SESSION_TIMEOUT_MS - 1_000);
439+
await state.storage.setAlarm(Date.now() - 1);
440+
});
441+
442+
await runDurableObjectAlarm(stub);
443+
444+
const response = await mcpPost({
445+
bearer,
446+
sessionId,
447+
body: TOOLS_LIST_REQUEST,
448+
});
449+
expect(response.status).toBe(200);
450+
const body = (await response.json()) as {
451+
readonly jsonrpc: string;
452+
readonly result?: { readonly tools?: ReadonlyArray<{ readonly name: string }> };
453+
};
454+
expect(body.jsonrpc).toBe("2.0");
455+
expect(body.result?.tools?.some((tool) => tool.name === "execute")).toBe(true);
456+
}, 15_000);
457+
420458
it("reproduces cross-account session reuse via leaked mcp-session-id", async () => {
421459
const victimOrgId = nextOrgId();
422460
const attackerOrgId = nextOrgId();
@@ -528,18 +566,19 @@ describe("McpSessionDO alarm lifecycle", () => {
528566
expect(stored.alarm).toBeLessThanOrEqual(Date.now() + HEARTBEAT_MS + 1_000);
529567
});
530568

531-
it("clears an expired session after a cold-started alarm", async () => {
569+
it("suspends an expired session after a cold-started alarm", async () => {
532570
const stub = env.MCP_SESSION.get(env.MCP_SESSION.newUniqueId());
571+
const sessionMeta = {
572+
organizationId: "org_alarm_expired",
573+
organizationName: "Alarm Expired",
574+
userId: "user_alarm_expired",
575+
};
576+
const lastActivity = Date.now() - SESSION_TIMEOUT_MS - 1_000;
533577

534578
await runInDurableObject(stub, async (_instance, state) => {
535-
const now = Date.now();
536-
await state.storage.put(SESSION_META_KEY, {
537-
organizationId: "org_alarm_expired",
538-
organizationName: "Alarm Expired",
539-
userId: "user_alarm_expired",
540-
});
541-
await state.storage.put(LAST_ACTIVITY_KEY, now - SESSION_TIMEOUT_MS - 1_000);
542-
await state.storage.setAlarm(now - 1);
579+
await state.storage.put(SESSION_META_KEY, sessionMeta);
580+
await state.storage.put(LAST_ACTIVITY_KEY, lastActivity);
581+
await state.storage.setAlarm(Date.now() - 1);
543582
});
544583
await runInDurableObject(stub, (instance) => {
545584
doActivityState(instance).lastActivityMs = 0;
@@ -553,8 +592,8 @@ describe("McpSessionDO alarm lifecycle", () => {
553592
alarm: await state.storage.getAlarm(),
554593
}));
555594

556-
expect(stored.sessionMeta).toBeUndefined();
557-
expect(stored.lastActivity).toBeUndefined();
595+
expect(stored.sessionMeta).toEqual(sessionMeta);
596+
expect(stored.lastActivity).toBe(lastActivity);
558597
expect(stored.alarm).toBeNull();
559598
});
560599
});

apps/cloud/src/mcp-session.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -668,7 +668,8 @@ export class McpSessionDO extends DurableObject {
668668
const lastActivityMs = await this.loadLastActivity();
669669
const idleMs = Date.now() - lastActivityMs;
670670
if (idleMs >= SESSION_TIMEOUT_MS) {
671-
await this.cleanup();
671+
await Effect.runPromise(this.closeRuntime());
672+
await this.ctx.storage.deleteAlarm();
672673
return;
673674
}
674675
await this.ctx.storage.setAlarm(Date.now() + HEARTBEAT_MS);

0 commit comments

Comments
 (0)