History

Fix session-id parsing, stranded-stream replay, priming, and restore races

Rhys Sullivan committed 2 months ago

Commit 803b0871 parent d2e97bc15 files · +521 −90Browse files

Unified · Split

15 files
  1. apps/cloud/src/auth/handlers.ts +6 −0
  2. apps/cloud/src/mcp/agent-handler.ts +9 −8
  3. apps/cloud/src/mcp/session-durable-object.ts +5 −6
  4. apps/host-cloudflare/src/mcp/agent-handler.ts +9 −8
  5. apps/host-cloudflare/src/mcp/index.ts +6 −2
  6. apps/host-cloudflare/src/mcp/session-durable-object.ts +5 −6
  7. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts +133 −4
  8. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +145 −42
  9. packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts +3 −5
  10. packages/hosts/cloudflare/src/mcp/do-event-store.test.ts +32 −0
  11. packages/hosts/cloudflare/src/mcp/do-event-store.ts +79 −0
  12. packages/hosts/cloudflare/src/mcp/modern-request-router.test.ts +36 −0
  13. packages/hosts/cloudflare/src/mcp/modern-request-router.ts +8 −2
  14. packages/hosts/cloudflare/src/mcp/session-stub.test.ts +30 −0
  15. packages/hosts/cloudflare/src/mcp/session-stub.ts +15 −7
apps/cloud/src/auth/handlers.ts+6−0
@@ -624,6 +624,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 623 unmodified lines
624624 Effect.gen(function* () {
625625 const owner = yield* requireSelectedOrganization;
626626 const stub = getMcpSessionStub(params.mcpSessionId);
627+ if (!stub) {
628+ return yield* new McpExecutionNotFoundError({ executionId: params.executionId });
629+ }
627630 const result = yield* Effect.promise(() =>
628631 stub.getPausedExecutionForApproval(params.executionId, {
629632 accountId: owner.accountId,
@@ -645,6 +648,9 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( · 15 unmodified lines
645648 Effect.gen(function* () {
646649 const owner = yield* requireSelectedOrganization;
647650 const stub = getMcpSessionStub(params.mcpSessionId);
651+ if (!stub) {
652+ return yield* new McpExecutionNotFoundError({ executionId: params.executionId });
653+ }
648654 const result = yield* Effect.promise(() =>
649655 stub.resumeExecutionForApproval(
650656 params.executionId,
apps/cloud/src/mcp/agent-handler.ts+9−8
@@ -161,11 +161,10 @@ export const makeCloudMcpAgentHandler = () => { · 160 unmodified lines
161161 // / JWKS failure) and `Unauthorized` (retry with a fresh token) must leave
162162 // the session intact, so the condemn path is gated on `Forbidden` alone.
163163 if (Predicate.isTagged(outcome, "Forbidden") && sessionId) {
164+ const session = mcpSessionStub(env.MCP_SESSION, sessionId);
164165 await Effect.runPromise(
165166 Effect.ignore(
166− Effect.tryPromise(() =>
167− mcpSessionStub(env.MCP_SESSION, sessionId)._cf_scheduleDestroy(),
168− ),
167+ session ? Effect.tryPromise(() => session._cf_scheduleDestroy()) : Effect.void,
169168 ),
170169 );
171170 }
@@ -212,8 +211,12 @@ export const makeCloudMcpAgentHandler = () => { · 40 unmodified lines
212211 });
213212 }
214213
215− if (sessionId) {
216− const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({
214+ const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null;
215+ if (sessionId && !existingSession) {
216+ return jsonRpcResponse(404, -32001, "Session not found");
217+ }
218+ if (existingSession) {
219+ const owner = await existingSession.validateMcpSessionOwner({
217220 accountId: outcome.principal.accountId,
218221 organizationId: outcome.principal.organizationId,
219222 });
@@ -244,9 +247,7 @@ export const makeCloudMcpAgentHandler = () => { · 24 unmodified lines
244247 ),
245248 propagation,
246249 );
247− const target = sessionId
248− ? mcpSessionStub(env.MCP_SESSION, sessionId)
249− : createMcpSessionStub(env.MCP_SESSION).stub;
250+ const target = existingSession ?? createMcpSessionStub(env.MCP_SESSION).stub;
250251 let response: Response;
251252 // oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary: a condemned DO abort can reject its direct fetch
252253 try {
apps/cloud/src/mcp/session-durable-object.ts+5−6
@@ -325,13 +325,12 @@ export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionD · 324 unmodified lines
325325 executionId: string,
326326 response: ResumeResponse,
327327 ): Effect.Effect<McpSessionModelResumeResult, unknown> {
328+ const ownerSession = mcpSessionStubForOwner(env.MCP_SESSION, owner);
329+ if (!ownerSession) {
330+ return Effect.succeed({ status: "execution_expired", ttlMs: PAUSED_APPROVAL_TIMEOUT_MS });
331+ }
328332 return Effect.tryPromise({
329− try: () =>
330− mcpSessionStubForOwner(env.MCP_SESSION, owner).resumeExecutionForModel(
331− executionId,
332− identity,
333− response,
334− ),
333+ try: () => ownerSession.resumeExecutionForModel(executionId, identity, response),
335334 catch: (cause) => new McpModelResumeForwardError({ cause }),
336335 });
337336 }
apps/host-cloudflare/src/mcp/agent-handler.ts+9−8
@@ -99,11 +99,10 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { · 98 unmodified lines
9999 const { auth, outcome } = await Effect.runPromise(authenticate(request, config));
100100 if (!Predicate.isTagged(outcome, "Authenticated")) {
101101 if (Predicate.isTagged(outcome, "Forbidden") && sessionId) {
102+ const session = mcpSessionStub(env.MCP_SESSION, sessionId);
102103 await Effect.runPromise(
103104 Effect.ignore(
104− Effect.tryPromise(() =>
105− mcpSessionStub(env.MCP_SESSION, sessionId)._cf_scheduleDestroy(),
106− ),
105+ session ? Effect.tryPromise(() => session._cf_scheduleDestroy()) : Effect.void,
107106 ),
108107 );
109108 }
@@ -140,8 +139,12 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { · 30 unmodified lines
140139 return new Response(null, { status: 204, headers: { "access-control-allow-origin": "*" } });
141140 }
142141
143− if (sessionId) {
144− const owner = await mcpSessionStub(env.MCP_SESSION, sessionId).validateMcpSessionOwner({
142+ const existingSession = sessionId ? mcpSessionStub(env.MCP_SESSION, sessionId) : null;
143+ if (sessionId && !existingSession) {
144+ return jsonRpcResponse(404, -32001, "Session not found");
145+ }
146+ if (existingSession) {
147+ const owner = await existingSession.validateMcpSessionOwner({
145148 accountId: outcome.principal.accountId,
146149 organizationId: outcome.principal.organizationId,
147150 });
@@ -170,9 +173,7 @@ export const makeCloudflareMcpAgentHandler = (config: CloudflareConfig) => { · 22 unmodified lines
170173 ),
171174 propagation,
172175 );
173− const target = sessionId
174− ? mcpSessionStub(env.MCP_SESSION, sessionId)
175− : createMcpSessionStub(env.MCP_SESSION).stub;
176+ const target = existingSession ?? createMcpSessionStub(env.MCP_SESSION).stub;
176177 return withMcpResponseHeaders(await target.fetch(forwarded));
177178 };
178179 };
apps/host-cloudflare/src/mcp/index.ts+6−2
@@ -35,7 +35,9 @@ export const makeCloudflareApprovalHandler = ( · 34 unmodified lines
3535
3636 const paused = PAUSED_PATH.exec(pathname);
3737 if (paused && request.method === "GET") {
38− const result = await stubFor(decodeURIComponent(paused[1]!)).getPausedExecutionForApproval(
38+ const stub = stubFor(decodeURIComponent(paused[1]!));
39+ if (!stub) return jsonResponse({ error: "Paused execution not found" }, 404);
40+ const result = await stub.getPausedExecutionForApproval(
3941 decodeURIComponent(paused[2]!),
4042 owner,
4143 );
@@ -53,7 +55,9 @@ export const makeCloudflareApprovalHandler = ( · 11 unmodified lines
5355 const response = raw === null ? null : decodeResumeResponse(raw);
5456 if (!response) return jsonResponse({ error: "Invalid approval response" }, 400);
5557
56− const result = await stubFor(decodeURIComponent(resume[1]!)).resumeExecutionForApproval(
58+ const stub = stubFor(decodeURIComponent(resume[1]!));
59+ if (!stub) return jsonResponse({ error: "Paused execution not found" }, 404);
60+ const result = await stub.resumeExecutionForApproval(
5761 decodeURIComponent(resume[2]!),
5862 owner,
5963 response,
apps/host-cloudflare/src/mcp/session-durable-object.ts+5−6
@@ -210,13 +210,12 @@ export class McpSessionDO extends McpAgentSessionDOBase<CloudflareEnv, CfSession · 209 unmodified lines
210210 executionId: string,
211211 response: ResumeResponse,
212212 ): Effect.Effect<McpSessionModelResumeResult, unknown> {
213+ const ownerSession = mcpSessionStubForOwner(this.cfEnv.MCP_SESSION, owner);
214+ if (!ownerSession) {
215+ return Effect.succeed({ status: "execution_expired", ttlMs: PAUSED_APPROVAL_TIMEOUT_MS });
216+ }
213217 return Effect.tryPromise({
214− try: () =>
215− mcpSessionStubForOwner(this.cfEnv.MCP_SESSION, owner).resumeExecutionForModel(
216− executionId,
217− identity,
218− response,
219− ),
218+ try: () => ownerSession.resumeExecutionForModel(executionId, identity, response),
220219 catch: (cause) => new McpModelResumeForwardError({ cause }),
221220 });
222221 }
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts+133−4
@@ -1,4 +1,4 @@
1−import { describe, expect, it } from "@effect/vitest";
1+import { afterEach, describe, expect, it, vi } from "@effect/vitest";
22 import { Cause, Effect } from "effect";
33 import { Client } from "@modelcontextprotocol/sdk/client/index.js";
44 import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
@@ -181,17 +181,28 @@ const engine: ExecutionEngine<Cause.YieldableError> = { · 176 unmodified lines
181181 getDescription: Effect.succeed("test Durable Object executor"),
182182 };
183183
184−class HarnessSession extends McpAgentSessionDOBase<Cloudflare.Env, { readonly end: () => void }> {
184+class HarnessSession extends McpAgentSessionDOBase<
185+ Cloudflare.Env,
186+ { readonly end: () => void | Promise<void> }
187+> {
185188 constructor(
186189 ctx: DurableObjectState,
187190 env: Cloudflare.Env,
188191 private readonly sessionEngine: ExecutionEngine<Cause.YieldableError> = engine,
192+ private readonly runtimeOptions: {
193+ readonly end?: () => void | Promise<void>;
194+ readonly sessionTimeoutMs?: number;
195+ } = {},
189196 ) {
190197 super(ctx, env);
191198 }
192199
193− protected override openSessionDb(): { readonly end: () => void } {
194− return { end: () => undefined };
200+ protected override openSessionDb(): { readonly end: () => void | Promise<void> } {
201+ return { end: this.runtimeOptions.end ?? (() => undefined) };
202+ }
203+
204+ protected override sessionTimeoutMs(): number {
205+ return this.runtimeOptions.sessionTimeoutMs ?? super.sessionTimeoutMs();
195206 }
196207
197208 protected override resolveSessionMeta(token: McpSessionInit): Effect.Effect<SessionMeta> {
@@ -257,6 +268,10 @@ const makeClientHarness = (state = new MemoryDurableObjectState()) => { · 59 unmodified lines
257268 };
258269
259270 describe("McpAgentSessionDOBase session serving", () => {
271+ afterEach(() => {
272+ vi.restoreAllMocks();
273+ });
274+
260275 it("serves and reuses a legacy v1 SDK client through the Durable Object", async () => {
261276 const harness = makeClientHarness();
262277 await harness.client.connect(harness.transport);
@@ -390,6 +405,120 @@ describe("McpAgentSessionDOBase session serving", () => { · 127 unmodified lines
390405 const replayBody = await replay.text();
391406 expect(replayBody).toContain("slow result");
392407 expect(replayBody).toContain(`id: ${replayEventId.slice(0, replayEventId.lastIndexOf(":"))}:`);
408+
409+ const standaloneReplay = await session.fetch(
410+ verifiedRequest(
411+ new Request("https://executor.test/mcp", {
412+ method: "GET",
413+ headers: {
414+ accept: "text/event-stream",
415+ "mcp-session-id": SESSION_ID,
416+ "mcp-protocol-version": "2025-06-18",
417+ },
418+ }),
419+ ),
420+ );
421+ const standaloneReplayBody = await standaloneReplay.text();
422+ expect(standaloneReplayBody).toContain("slow result");
423+ expect(standaloneReplayBody).toContain("event: message");
424+ await state.flushWaitUntil();
425+ });
426+
427+ it("restores once while an idle runtime generation is still closing", async () => {
428+ let now = 1_000;
429+ vi.spyOn(Date, "now").mockImplementation(() => now);
430+ let closeStarted = (): void => undefined;
431+ const closing = new Promise<void>((resolve) => {
432+ closeStarted = resolve;
433+ });
434+ let finishClose = (): void => undefined;
435+ const closeGate = new Promise<void>((resolve) => {
436+ finishClose = resolve;
437+ });
438+ let closeCount = 0;
439+ const state = new MemoryDurableObjectState();
440+ const session = new HarnessSession(state, {} as Cloudflare.Env, engine, {
441+ sessionTimeoutMs: 10,
442+ end: () => {
443+ closeCount += 1;
444+ if (closeCount !== 1) return;
445+ closeStarted();
446+ return closeGate;
447+ },
448+ });
449+ const post = (body: unknown): Request =>
450+ verifiedRequest(
451+ new Request("https://executor.test/mcp", {
452+ method: "POST",
453+ headers: {
454+ accept: "application/json, text/event-stream",
455+ "content-type": "application/json",
456+ "mcp-session-id": SESSION_ID,
457+ "mcp-protocol-version": "2025-06-18",
458+ },
459+ body: JSON.stringify(body),
460+ }),
461+ );
462+
463+ const initialize = await session.fetch(
464+ verifiedRequest(
465+ new Request("https://executor.test/mcp", {
466+ method: "POST",
467+ headers: {
468+ accept: "application/json, text/event-stream",
469+ "content-type": "application/json",
470+ },
471+ body: JSON.stringify({
472+ jsonrpc: "2.0",
473+ id: "initialize",
474+ method: "initialize",
475+ params: {
476+ protocolVersion: "2025-06-18",
477+ capabilities: {},
478+ clientInfo: { name: "restore-race", version: "1.0.0" },
479+ },
480+ }),
481+ }),
482+ ),
483+ );
484+ await initialize.text();
485+ await session.fetch(post({ jsonrpc: "2.0", method: "notifications/initialized", params: {} }));
486+
487+ now += 100;
488+ const alarm = session.alarm();
489+ await closing;
490+
491+ const get = session.fetch(
492+ verifiedRequest(
493+ new Request("https://executor.test/mcp", {
494+ method: "GET",
495+ headers: {
496+ accept: "text/event-stream",
497+ "mcp-session-id": SESSION_ID,
498+ "mcp-protocol-version": "2025-06-18",
499+ },
500+ }),
501+ ),
502+ );
503+ const list = session.fetch(
504+ post({ jsonrpc: "2.0", id: "concurrent-list", method: "tools/list", params: {} }),
505+ );
506+
507+ finishClose();
508+ const [getResponse, listResponse] = await Promise.all([get, list]);
509+ expect(getResponse.status).toBe(200);
510+ await getResponse.body?.cancel();
511+ expect(listResponse.status).toBe(200);
512+ expect(await listResponse.text()).toContain("execute");
513+ await alarm;
514+ await expect(state.storage.get("executor:mcp:v2:last-activity-ms")).resolves.toBe(now);
515+ await expect(state.storage.getAlarm()).resolves.toBe(now + 10);
516+
517+ const followUp = await session.fetch(
518+ post({ jsonrpc: "2.0", id: "follow-up-list", method: "tools/list", params: {} }),
519+ );
520+ expect(followUp.status).toBe(200);
521+ expect(await followUp.text()).toContain("execute");
393522 });
394523
395524 it("persists session metadata and rejects a different principal", async () => {
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts+145−42
@@ -256,6 +256,21 @@ const legacyPrimingFrame = (eventId: string): Uint8Array => · 255 unmodified lines
256256 `event: mcp-priming\nid: ${eventId}\ndata: ${JSON.stringify(LEGACY_PRIMING_MESSAGE)}\n\n`,
257257 );
258258
259+const replayFrame = (eventId: string, message: JSONRPCMessage): Uint8Array =>
260+ new TextEncoder().encode(`event: message\nid: ${eventId}\ndata: ${JSON.stringify(message)}\n\n`);
261+
262+const combineFrames = (frames: readonly Uint8Array[]): ArrayBuffer => {
263+ const byteLength = frames.reduce((total, frame) => total + frame.byteLength, 0);
264+ const buffer = new ArrayBuffer(byteLength);
265+ const combined = new Uint8Array(buffer);
266+ let offset = 0;
267+ for (const frame of frames) {
268+ combined.set(frame, offset);
269+ offset += frame.byteLength;
270+ }
271+ return buffer;
272+};
273+
259274 const mcpResourceFromKey = (resourceKey: string): McpResource =>
260275 resourceKey.startsWith("toolkit:") && resourceKey.length > "toolkit:".length
261276 ? { kind: "toolkit", slug: resourceKey.slice("toolkit:".length) }
@@ -552,6 +567,7 @@ export abstract class McpAgentSessionDOBase< · 290 unmodified lines
552567
553568 private async disposeIdleRuntime(input: {
554569 readonly idleMs: number;
570+ readonly lastActivityMs: number;
555571 readonly pausedExecutionCount: number;
556572 }): Promise<void> {
557573 console.info(
@@ -563,6 +579,7 @@ export abstract class McpAgentSessionDOBase< · 5 unmodified lines
563579 }),
564580 );
565581 await Effect.runPromise(this.closeRuntime());
582+<<<<<<< HEAD
566583 await Effect.runPromise(
567584 Effect.all([
568585 Effect.ignore(Effect.tryPromise(() => this.ctx.storage.deleteAlarm())),
@@ -573,6 +590,18 @@ export abstract class McpAgentSessionDOBase< · 4 unmodified lines
573590 ),
574591 ]),
575592 );
593+=======
594+ const activityKey =
595+ this.runtimeKind === "modern" ? MODERN_LAST_ACTIVITY_KEY : LEGACY_V2_LAST_ACTIVITY_KEY;
596+ const cleared = await this.ctx.storage.transaction(async (transaction) => {
597+ const current = await transaction.get<number>(activityKey);
598+ if (current !== input.lastActivityMs) return false;
599+ await transaction.delete([LEGACY_V2_LAST_ACTIVITY_KEY, MODERN_LAST_ACTIVITY_KEY]);
600+ await transaction.deleteAlarm();
601+ return true;
602+ });
603+ if (cleared) this.lastActivityMs = 0;
604+>>>>>>> d62e666df (Fix session-id parsing, stranded-stream replay, priming, and restore races)
576605 }
577606
578607 private resolveAndStoreSessionMeta(token: McpSessionInit) {
@@ -823,34 +852,39 @@ export abstract class McpAgentSessionDOBase< · 244 unmodified lines
823852 private closeRuntime(): Effect.Effect<void> {
824853 const self = this;
825854 return Effect.gen(function* () {
826− yield* self.releaseAllPendingApprovalLeases();
827− if (self.transport) {
828− const transport = self.transport;
829− self.transport = null;
830− yield* Effect.promise(() => transport.close()).pipe(Effect.ignore);
831− }
832− if (self.server) {
833− const server = self.server;
834− delete (self as { server?: McpServer }).server;
835− yield* Effect.promise(() => server.close()).pipe(Effect.ignore);
836− }
837− if (self.modernHandler) {
838− const handler = self.modernHandler;
839− self.modernHandler = null;
840− yield* Effect.promise(() => handler.close()).pipe(Effect.ignore);
841− }
855+ // Detach the complete generation before awaiting cleanup. A request that
856+ // interleaves with a slow server/DB close must build a fresh generation,
857+ // never observe initialized=true with a closing/null transport, and the
858+ // old cleanup must never clear fields belonging to that fresh runtime.
859+ const transport = self.transport;
860+ const server = self.server;
861+ const modernHandler = self.modernHandler;
862+ const dbHandle = self.dbHandle;
863+ self.transport = null;
864+ delete (self as { server?: McpServer }).server;
865+ self.modernHandler = null;
866+ self.dbHandle = null;
842867 self.engine = null;
843868 self.modernRuntime = null;
844869 self.activeLegacyStreamCount = 0;
845870 self.legacyRunningRequestCount = 0;
846871 self.modernRequestBodies = new WeakMap<Request, unknown>();
847872 self.modernRequestPropagation = new WeakMap<Request, IncomingTraceHeaders | undefined>();
848− if (self.dbHandle) {
849− const dbHandle = self.dbHandle;
850− self.dbHandle = null;
873+ self.initialized = false;
874+
875+ yield* self.releaseAllPendingApprovalLeases();
876+ if (transport) {
877+ yield* Effect.promise(() => transport.close()).pipe(Effect.ignore);
878+ }
879+ if (server) {
880+ yield* Effect.promise(() => server.close()).pipe(Effect.ignore);
881+ }
882+ if (modernHandler) {
883+ yield* Effect.promise(() => modernHandler.close()).pipe(Effect.ignore);
884+ }
885+ if (dbHandle) {
851886 yield* Effect.promise(() => Promise.resolve(dbHandle.end())).pipe(Effect.ignore);
852887 }
853− self.initialized = false;
854888 });
855889 }
856890
@@ -1000,6 +1034,69 @@ export abstract class McpAgentSessionDOBase< · 143 unmodified lines
10001034 return typeof streamId === "string" ? streamId : null;
10011035 }
10021036
1037+ private async supersedeReplayStream(
1038+ transport: WebStandardStreamableHTTPServerTransport,
1039+ lastEventId: string,
1040+ ): Promise<void> {
1041+ const streamId = await this.eventStore.getStreamIdForEventId(lastEventId);
1042+ if (!streamId) return;
1043+ // SAFETY: the installed SDK exposes closeSSEStream(requestId), but not the
1044+ // reverse request-id map needed to supersede a stale POST connection before
1045+ // replay. This is the same pinned map used by requestStreamId above.
1046+ const mapping: unknown = Reflect.get(transport, "_requestToStreamMapping");
1047+ if (!(mapping instanceof Map)) return;
1048+ for (const [requestId, mappedStreamId] of mapping) {
1049+ if (
1050+ mappedStreamId === streamId &&
1051+ (typeof requestId === "string" || typeof requestId === "number")
1052+ ) {
1053+ transport.closeSSEStream(requestId);
1054+ return;
1055+ }
1056+ }
1057+ }
1058+
1059+ private trackedLegacyResponse = (
1060+ response: Response,
1061+ options: { readonly initialFrame?: Uint8Array; readonly acknowledge?: readonly string[] } = {},
1062+ ): Response =>
1063+ rotateSseResponse(response, {
1064+ ...(options.initialFrame ? { initialFrame: options.initialFrame } : {}),
1065+ onOpen: () => {
1066+ this.activeLegacyStreamCount += 1;
1067+ },
1068+ onClose: (reason) => {
1069+ this.activeLegacyStreamCount = Math.max(0, this.activeLegacyStreamCount - 1);
1070+ if (reason === "complete" && options.acknowledge && options.acknowledge.length > 0) {
1071+ this.ctx.waitUntil(this.eventStore.acknowledgeUndeliveredStreams(options.acknowledge));
1072+ }
1073+ },
1074+ });
1075+
1076+ private async replayUndeliveredOnStandaloneGet(request: Request): Promise<Response | null> {
1077+ if (request.method !== "GET" || request.headers.has("last-event-id")) return null;
1078+ const frames: Uint8Array[] = [];
1079+ const streamIds = await this.eventStore.replayUndeliveredStreams({
1080+ send: (eventId, message) => {
1081+ frames.push(replayFrame(eventId, message));
1082+ return Promise.resolve();
1083+ },
1084+ });
1085+ if (frames.length === 0) return null;
1086+ return this.trackedLegacyResponse(
1087+ new Response(combineFrames(frames), {
1088+ headers: {
1089+ "content-type": "text/event-stream",
1090+ "cache-control": "no-cache, no-transform",
1091+ connection: "keep-alive",
1092+ "x-accel-buffering": "no",
1093+ "mcp-session-id": this.sessionId,
1094+ },
1095+ }),
1096+ { acknowledge: streamIds },
1097+ );
1098+ }
1099+
10031100 private async serializedTransportRequest<A>(run: () => Promise<A>): Promise<A> {
10041101 const previous = this.transportRequestTail;
10051102 let release = (): void => undefined;
@@ -1019,25 +1116,34 @@ export abstract class McpAgentSessionDOBase< · 13 unmodified lines
10191116 request: Request,
10201117 parsedBody: unknown,
10211118 ): Promise<Response> {
1022− const transport = this.transport;
1023− if (!transport) {
1024− return jsonRpcErrorBody(404, -32001, "Session not found", { cors: false });
1025− }
10261119 return this.serializedTransportRequest(async () => {
1120+ const transport = this.transport;
1121+ if (!transport) {
1122+ return jsonRpcErrorBody(404, -32001, "Session not found", { cors: false });
1123+ }
1124+ if (request.method === "GET") {
1125+ const lastEventId = request.headers.get("last-event-id");
1126+ if (lastEventId) {
1127+ await this.supersedeReplayStream(transport, lastEventId);
1128+ } else {
1129+ // Latest-listener-wins. Client cancellation is not reliably relayed
1130+ // through every workerd/Vite streaming hop, so explicitly retire a
1131+ // stale standalone mapping before opening its replacement.
1132+ transport.closeStandaloneSSEStream();
1133+ const replay = await this.replayUndeliveredOnStandaloneGet(request);
1134+ if (replay) return replay;
1135+ }
1136+ }
10271137 const toolCallIds = legacyToolCallRequestIds(parsedBody);
10281138 const protocolVersion =
10291139 request.headers.get("mcp-protocol-version") ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION;
10301140 const needsLegacyPrime =
10311141 toolCallIds.length > 0 && protocolVersion < LEGACY_PRIMING_PROTOCOL_VERSION;
10321142 if (!needsLegacyPrime) {
1033− return rotateSseResponse(await transport.handleRequest(request), {
1034− onOpen: () => {
1035− this.activeLegacyStreamCount += 1;
1036− },
1037− onClose: () => {
1038− this.activeLegacyStreamCount = Math.max(0, this.activeLegacyStreamCount - 1);
1039− },
1040− });
1143+ const response = await transport.handleRequest(request);
1144+ const streamId = toolCallIds[0] ? this.requestStreamId(transport, toolCallIds[0]) : null;
1145+ if (streamId) await this.eventStore.markStreamUndelivered(streamId);
1146+ return this.trackedLegacyResponse(response);
10411147 }
10421148
10431149 const originalOnMessage = transport.onmessage;
@@ -1057,17 +1163,14 @@ export abstract class McpAgentSessionDOBase< · 13 unmodified lines
10571163 const eventId = streamId
10581164 ? await this.eventStore.storeEvent(streamId, LEGACY_PRIMING_MESSAGE)
10591165 : null;
1060− for (const item of queued) originalOnMessage?.(item.message, item.extra);
1061−
1062− return rotateSseResponse(response, {
1166+ if (streamId) await this.eventStore.markStreamUndelivered(streamId);
1167+ const rotated = this.trackedLegacyResponse(response, {
10631168 ...(eventId ? { initialFrame: legacyPrimingFrame(eventId) } : {}),
1064− onOpen: () => {
1065− this.activeLegacyStreamCount += 1;
1066− },
1067− onClose: () => {
1068− this.activeLegacyStreamCount = Math.max(0, this.activeLegacyStreamCount - 1);
1069− },
10701169 });
1170+ // ReadableStream.start enqueues the priming frame synchronously while
1171+ // building `rotated`; only then may the server see the tools/call.
1172+ for (const item of queued) originalOnMessage?.(item.message, item.extra);
1173+ return rotated;
10711174 });
10721175 }
10731176
@@ -1444,7 +1547,7 @@ export abstract class McpAgentSessionDOBase< · 370 unmodified lines
14441547 return;
14451548 }
14461549
1447− await this.disposeIdleRuntime({ idleMs, pausedExecutionCount });
1550+ await this.disposeIdleRuntime({ idleMs, lastActivityMs, pausedExecutionCount });
14481551 }
14491552
14501553 private validateApprovalIdentity(
packages/hosts/cloudflare/src/mcp/agent-session-model-resume.test.ts+3−5
@@ -425,11 +425,9 @@ describe("McpAgentSessionDOBase cross-session model resume", () => { · 424 unmodified lines
425425 response: ResumeResponse,
426426 ) =>
427427 Effect.promise(async () => {
428− return mcpSessionStub(sessionNamespace, owner.sessionId).resumeExecutionForModel(
429− executionId,
430− identity,
431− response,
432− );
428+ const ownerSession = mcpSessionStub(sessionNamespace, owner.sessionId);
429+ if (!ownerSession) return { status: "execution_expired" as const, ttlMs: 0 };
430+ return ownerSession.resumeExecutionForModel(executionId, identity, response);
433431 }),
434432 );
435433
packages/hosts/cloudflare/src/mcp/do-event-store.test.ts+32−0
@@ -113,6 +113,38 @@ describe("DurableObjectMcpEventStore", () => { · 112 unmodified lines
113113 expect(replayed).toEqual([second]);
114114 });
115115
116+ it("replays a marked POST stream on standalone recovery and clears it after acknowledgement", async () => {
117+ const { storage, store } = makeStore();
118+ await store.markStreamUndelivered("post-stream");
119+ const prime = await store.storeEvent("post-stream", {
120+ jsonrpc: "2.0",
121+ method: "notifications/message",
122+ params: { level: "debug", data: "prime" },
123+ });
124+ const result = await store.storeEvent("post-stream", {
125+ jsonrpc: "2.0",
126+ id: "call-1",
127+ result: { content: [{ type: "text", text: "completed" }] },
128+ });
129+ const replayed: string[] = [];
130+
131+ const streamIds = await store.replayUndeliveredStreams({
132+ send: (eventId) => {
133+ replayed.push(eventId);
134+ return Promise.resolve();
135+ },
136+ });
137+
138+ expect(streamIds).toEqual(["post-stream"]);
139+ expect(replayed).toEqual([prime, result]);
140+
141+ await store.acknowledgeUndeliveredStreams(streamIds);
142+ await expect(
143+ store.replayUndeliveredStreams({ send: () => Promise.resolve() }),
144+ ).resolves.toEqual([]);
145+ expect(eventKeys(storage)).toEqual([]);
146+ });
147+
116148 it("evicts oldest events at the byte cap but never the newest", async () => {
117149 const { storage, store } = makeStore();
118150 const bigMessage = (marker: string) => ({
packages/hosts/cloudflare/src/mcp/do-event-store.ts+79−0
@@ -1,6 +1,7 @@
11 import type { EventId, EventStore, JSONRPCMessage, StreamId } from "@modelcontextprotocol/server";
22
33 const EVENT_KEY_PREFIX = "executor:mcp:v2:event:";
4+const UNDELIVERED_STREAM_KEY_PREFIX = "executor:mcp:v2:undelivered-stream:";
45 const SEQUENCE_WIDTH = 16;
56 const REPLAY_LIMIT = 1_000;
67 const DELETE_CHUNK_SIZE = 128;
@@ -26,6 +27,9 @@ type StoredEntry = { · 19 unmodified lines
2627
2728 const eventPrefix = (streamId: StreamId): string => `${EVENT_KEY_PREFIX}${streamId}:`;
2829
30+const undeliveredStreamKey = (streamId: StreamId): string =>
31+ `${UNDELIVERED_STREAM_KEY_PREFIX}${streamId}`;
32+
2933 const eventIdFromKey = (key: string): EventId => key.slice(EVENT_KEY_PREFIX.length);
3034
3135 const streamIdFromEventId = (eventId: EventId): StreamId | undefined => {
@@ -165,6 +169,81 @@ export class DurableObjectMcpEventStore implements EventStore { · 133 unmodified lines
165169 return Promise.resolve(streamIdFromEventId(eventId));
166170 }
167171
172+ /**
173+ * Mark a tool-call stream as requiring at-least-once delivery confirmation.
174+ *
175+ * Direct workerd streaming cannot prove that a completed POST body reached
176+ * the remote client, so the marker remains until a later standalone GET
177+ * drains the stream and its response body completes.
178+ */
179+ async markStreamUndelivered(streamId: StreamId): Promise<void> {
180+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- storage boundary: a marker failure cannot block the live tool request
181+ try {
182+ await this.storage.put(undeliveredStreamKey(streamId), true);
183+ } catch {
184+ logStoreWarning("mcp_event_store_put_failed", {
185+ operation: "mark_undelivered",
186+ streamId,
187+ });
188+ }
189+ }
190+
191+ /** Replay every marked POST stream onto a standalone recovery response. */
192+ async replayUndeliveredStreams({
193+ send,
194+ }: {
195+ readonly send: (eventId: EventId, message: JSONRPCMessage) => Promise<void>;
196+ }): Promise<readonly StreamId[]> {
197+ const replayedStreamIds: StreamId[] = [];
198+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- storage/replay boundary: recovery is best-effort and leaves markers intact for a later GET
199+ try {
200+ const markers = await this.storage.list<boolean>({
201+ prefix: UNDELIVERED_STREAM_KEY_PREFIX,
202+ limit: REPLAY_LIMIT,
203+ });
204+ for (const key of markers.keys()) {
205+ const streamId = key.slice(UNDELIVERED_STREAM_KEY_PREFIX.length);
206+ let replayed = false;
207+ const rows = await this.storage.list<JSONRPCMessage>({
208+ prefix: eventPrefix(streamId),
209+ limit: REPLAY_LIMIT,
210+ });
211+ for (const [eventKey, message] of rows) {
212+ await send(eventIdFromKey(eventKey), message);
213+ replayed = true;
214+ }
215+ if (replayed) replayedStreamIds.push(streamId);
216+ }
217+ } catch {
218+ logStoreWarning("mcp_event_store_list_failed", {
219+ operation: "replay_undelivered",
220+ });
221+ }
222+ return replayedStreamIds;
223+ }
224+
225+ /** Clear successfully drained recovery streams and their delivery markers. */
226+ async acknowledgeUndeliveredStreams(streamIds: readonly StreamId[]): Promise<void> {
227+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- storage boundary: acknowledgement cleanup is best-effort and duplicate replay is safe
228+ try {
229+ for (const streamId of streamIds) {
230+ const rows = await this.storage.list<JSONRPCMessage>({
231+ prefix: eventPrefix(streamId),
232+ limit: REPLAY_LIMIT,
233+ });
234+ const keys = [undeliveredStreamKey(streamId), ...rows.keys()];
235+ for (let index = 0; index < keys.length; index += DELETE_CHUNK_SIZE) {
236+ await this.storage.delete(keys.slice(index, index + DELETE_CHUNK_SIZE));
237+ }
238+ }
239+ } catch {
240+ logStoreWarning("mcp_event_store_delete_failed", {
241+ operation: "acknowledge_undelivered",
242+ streamCount: streamIds.length,
243+ });
244+ }
245+ }
246+
168247 /** Replay persisted events after the supplied ID, in storage-key order. */
169248 async replayEventsAfter(
170249 lastEventId: EventId,
packages/hosts/cloudflare/src/mcp/modern-request-router.test.ts+36−0
@@ -120,6 +120,8 @@ class MemorySessions implements McpModernSessionNamespace<string> { · 119 unmodified lines
120120 readonly forwarded: ForwardedRequest[] = [];
121121 uniqueIds = 0;
122122
123+ constructor(private readonly rejectStringIds = false) {}
124+
123125 newUniqueId(): string {
124126 this.uniqueIds += 1;
125127 return `unique-${this.uniqueIds}`;
@@ -130,6 +132,10 @@ class MemorySessions implements McpModernSessionNamespace<string> { · 4 unmodified lines
130132 }
131133
132134 idFromString(id: string): string {
135+ if (this.rejectStringIds) {
136+ // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test boundary: model Cloudflare rejecting a foreign/checksum-invalid owner id
137+ throw new Error("invalid Durable Object id");
138+ }
133139 return `id:${id}`;
134140 }
135141
@@ -352,6 +358,36 @@ describe("modern Cloudflare MCP worker routing", () => { · 216 unmodified lines
352358 expect(sessions.forwarded.map(({ id }) => id)).toEqual(["id:legacy-session"]);
353359 });
354360
361+ it("falls back to the worker when a persisted modern owner id is invalid", async () => {
362+ const executionId = "exec-invalid-owner";
363+ const directory = new MemoryDirectory();
364+ directory.records.set(executionId, {
365+ executionId,
366+ owner: modernMcpExecutionOwnerRoute("foreign-do-id"),
367+ accountId: principal.accountId,
368+ organizationId: principal.organizationId,
369+ expiresAt: new Date(Date.now() + 60_000).toISOString(),
370+ ttlMs: 60_000,
371+ });
372+ const sessions = new MemorySessions(true);
373+ const builds = { count: 0 };
374+
375+ const response = await dispatch({
376+ body: modernBody({
377+ method: "tools/call",
378+ name: "resume",
379+ arguments: { executionId, action: "accept" },
380+ }),
381+ sessions,
382+ directory,
383+ builder: makeBuilder(builds),
384+ });
385+
386+ expect(await response.json()).toMatchObject({ error: { code: -32602 } });
387+ expect(builds.count).toBe(1);
388+ expect(sessions.forwarded).toEqual([]);
389+ });
390+
355391 it("rejects tampered and expired continuation state without touching a DO", async () => {
356392 const valid = await mintRequestState("exec-invalid");
357393 const middle = Math.floor(valid.length / 2);
packages/hosts/cloudflare/src/mcp/modern-request-router.ts+8−2
@@ -134,7 +134,10 @@ const toModernSessionStub = (stub: unknown): McpModernSessionStub => · 133 unmodified lines
134134 const stubForOwner = <Id>(
135135 sessions: McpModernSessionNamespace<Id>,
136136 owner: { readonly sessionId: string },
137−): McpModernSessionStub => toModernSessionStub(mcpSessionStubForOwner(sessions, owner));
137+): McpModernSessionStub | null => {
138+ const stub = mcpSessionStubForOwner(sessions, owner);
139+ return stub ? toModernSessionStub(stub) : null;
140+};
138141
139142 const freshStub = <Id>(sessions: McpModernSessionNamespace<Id>): McpModernSessionStub =>
140143 toModernSessionStub(sessions.get(sessions.newUniqueId()));
@@ -240,7 +243,10 @@ export const makeMcpModernRequestRouter = (): McpModernRequestRouter => { · 99 unmodified lines
240243 jsonRpcErrorBody(403, -32003, "MCP execution does not belong to the current bearer"),
241244 );
242245 }
243− return withModernMcpCors(await serveDo(stubForOwner(input.sessions, owner.owner), input));
246+ const ownerStub = stubForOwner(input.sessions, owner.owner);
247+ return withModernMcpCors(
248+ ownerStub ? await serveDo(ownerStub, input) : await serveWorker(input),
249+ );
244250 },
245251 close: () =>
246252 Promise.all(Array.from(handlers.values(), (handler) => handler.close())).then(
packages/hosts/cloudflare/src/mcp/session-stub.test.ts+30−0
@@ -0,0 +1,30 @@
1+import { describe, expect, it, vi } from "@effect/vitest";
2+
3+import { mcpSessionStub } from "./session-stub";
4+
5+describe("mcpSessionStub", () => {
6+ it("returns null when Cloudflare rejects a client-supplied Durable Object id", () => {
7+ const get = vi.fn();
8+ const namespace = {
9+ idFromString: (_id: string): string => {
10+ // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- test boundary: model Cloudflare's throwing namespace checksum parser
11+ throw new Error("Durable Object ID is not valid for this namespace");
12+ },
13+ get,
14+ };
15+
16+ expect(mcpSessionStub(namespace, "0".repeat(64))).toBeNull();
17+ expect(get).not.toHaveBeenCalled();
18+ });
19+
20+ it("resolves a namespace-validated id to its generated RPC stub", () => {
21+ const stub = { fetch: vi.fn() };
22+ const namespace = {
23+ idFromString: (id: string): string => `parsed:${id}`,
24+ get: vi.fn(() => stub),
25+ };
26+
27+ expect(mcpSessionStub(namespace, "issued-id")).toBe(stub);
28+ expect(namespace.get).toHaveBeenCalledWith("parsed:issued-id");
29+ });
30+});
packages/hosts/cloudflare/src/mcp/session-stub.ts+15−7
@@ -49,9 +49,19 @@ export interface McpSessionStub { · 48 unmodified lines
4949 export const mcpSessionStub = <Id>(
5050 namespace: McpSessionNamespace<Id>,
5151 sessionId: string,
52−): McpSessionStub =>
53− // oxlint-disable-next-line executor/no-double-cast -- boundary: Workers types expose only DurableObjectStub, but fetch and RPC methods are generated from the bound DO class.
54− namespace.get(namespace.idFromString(sessionId)) as unknown as McpSessionStub;
52+): McpSessionStub | null => {
53+ let id: Id;
54+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- platform boundary: Cloudflare validates the namespace checksum only through throwing idFromString
55+ try {
56+ id = namespace.idFromString(sessionId);
57+ } catch {
58+ return null;
59+ }
60+ return (
61+ // oxlint-disable-next-line executor/no-double-cast -- boundary: Workers types expose only DurableObjectStub, but fetch and RPC methods are generated from the bound DO class.
62+ namespace.get(id) as unknown as McpSessionStub
63+ );
64+};
5565
5666 /** Allocate one unique session DO and return its client-visible ID and stub. */
5767 export const createMcpSessionStub = <Id>(
@@ -69,9 +79,7 @@ export const createMcpSessionStub = <Id>( · 11 unmodified lines
6979 export const mcpSessionStubForOwner = <Id>(
7080 namespace: McpOwnerSessionNamespace<Id>,
7181 owner: McpExecutionOwnerRoute,
72−): McpSessionStub => {
82+): McpSessionStub | null => {
7383 const modernId = modernMcpDurableObjectId(owner);
74− const id = namespace.idFromString(modernId ?? owner.sessionId);
75− // oxlint-disable-next-line executor/no-double-cast -- boundary: Workers generates this RPC surface from the bound DO class.
76− return namespace.get(id) as unknown as McpSessionStub;
84+ return mcpSessionStub(namespace, modernId ?? owner.sessionId);
7785 };