From 64a3faf62d2b75b01abcdeb458be1b5cfc02d79e Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:22:48 -0700 Subject: [PATCH 1/2] Deliver MCP responses larger than the DO storage value cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DO transport persisted every outbound message for reconnect replay BEFORE writing the live SSE frame, and storage.put of a value over the 128 KiB DO cap throws — so an oversize response (the ~5MB ui://executor/shell.html resource) was neither stored nor delivered and the client hung on keepalives forever. Found live in prod after #1502 made the shell resource actually serve its bytes. - storeEvent skips persistence for messages over a 120 KiB guard, logging mcp_event_store_skipped_oversize and returning undefined; the caller delivers the live frame without a replay id. - sendOnStream treats any storeEvent failure as best-effort: log, then still write the live SSE frame. - New e2e (cloud/mcp-oversize-response.test.ts) drives resources/read of the shell over real workerd and fails on a hang. - Event-store unit tests updated for the skip contract. --- .changeset/tidy-pianos-shake.md | 7 + e2e/cloud/mcp-oversize-response.test.ts | 136 ++++++++++++++++++ .../src/mcp/agents-event-store.test.ts | 53 +++++-- .../src/mcp/agents-priming-event.test.ts | 15 +- patches/agents@0.17.3.patch | 98 +++++++++++-- 5 files changed, 281 insertions(+), 28 deletions(-) create mode 100644 .changeset/tidy-pianos-shake.md create mode 100644 e2e/cloud/mcp-oversize-response.test.ts diff --git a/.changeset/tidy-pianos-shake.md b/.changeset/tidy-pianos-shake.md new file mode 100644 index 000000000..c21047e4d --- /dev/null +++ b/.changeset/tidy-pianos-shake.md @@ -0,0 +1,7 @@ +--- +"executor": patch +--- + +**Fix: MCP responses larger than Durable Object storage's 128 KiB per-value cap hung the client instead of being delivered** + +The DO transport persisted every outbound message for reconnect replay before writing the live SSE frame, and `storage.put` of an oversize value throws — so a large response (the `ui://executor/shell.html` resource is ~5 MB) was neither stored nor sent, and the client waited on keepalives forever. The transport now delivers the live frame first and treats persistence as best-effort: an oversize message skips the event store with a logged warning and arrives without a replay id, which only costs replayability if the connection drops mid-delivery. diff --git a/e2e/cloud/mcp-oversize-response.test.ts b/e2e/cloud/mcp-oversize-response.test.ts new file mode 100644 index 000000000..83b72e2de --- /dev/null +++ b/e2e/cloud/mcp-oversize-response.test.ts @@ -0,0 +1,136 @@ +// Cloud: an MCP response bigger than DO storage's 128 KiB per-value cap is +// still delivered. The ui://executor/shell.html resource (~5 MB of inlined +// shell document) is the production case: the transport used to persist every +// outbound message with storage.put BEFORE writing the live SSE frame, so the +// put's failure killed the send and the client saw keepalives forever — every +// artifact widget in every remote MCP client hung, with no error anywhere +// (found live in prod, 2026-07-29). The patched transport delivers live first +// and treats persistence as best-effort, so this pins the whole path over the +// real workerd topology: POST resources/read → McpSessionDO → ASSETS-backed +// shell loader → event store skip (oversize) → live SSE frame → this client. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Mcp, Target } from "../src/services"; +import type { Identity } from "../src/target"; + +const PROTOCOL_VERSION = "2025-03-26"; +const JSON_AND_SSE = "application/json, text/event-stream"; + +const SHELL_RESOURCE_URI = "ui://executor/shell.html"; + +const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label; +// DO storage's per-value cap; anything beyond it exercises the skip path. +const DO_VALUE_CAP_BYTES = 128 * 1024; + +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), + }); + +const openSession = async (mcpUrl: string, bearer: string): Promise => { + const initialized = await postJson(mcpUrl, bearer, { + jsonrpc: "2.0" as const, + id: "initialize", + method: "initialize", + params: { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "executor-e2e-oversize-response", version: "0.0.1" }, + }, + }); + const sessionId = initialized.headers.get("mcp-session-id"); + await initialized.text(); + if (initialized.status !== 200 || !sessionId) { + throw new Error(`openSession: initialize failed (${initialized.status})`); + } + const notification = await postJson( + mcpUrl, + bearer, + { jsonrpc: "2.0" as const, method: "notifications/initialized" }, + sessionId, + ); + await notification.text(); + expect(notification.status, "notifications/initialized is accepted").toBe(202); + return sessionId; +}; + +/** Read the POST's SSE body to completion and return the JSON-RPC payloads. */ +const readSseMessages = async ( + response: Response, +): Promise> => { + const text = await response.text(); + const messages: Array<{ readonly id?: unknown; readonly result?: unknown }> = []; + for (const block of text.replace(/\r/g, "").split("\n\n")) { + const data = block + .split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).trimStart()) + .join("\n") + .trim(); + if (data) messages.push(JSON.parse(data) as { readonly id?: unknown }); + } + return messages; +}; + +scenario( + "MCP streamable HTTP · a response beyond the DO storage value cap is delivered live (the ui:// shell resource)", + {}, + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const identity = yield* target.newIdentity(); + const bearer = yield* mcp.mintBearer(emailOf(identity)); + // Artifacts opt-in is what registers the shell resource on the session. + const mcpUrl = `${target.mcpUrl}?artifacts=true`; + + const sessionId = yield* Effect.promise(() => openSession(mcpUrl, bearer)); + + const read = yield* Effect.promise(() => + postJson( + mcpUrl, + bearer, + { + jsonrpc: "2.0" as const, + id: "read-shell", + method: "resources/read", + params: { uri: SHELL_RESOURCE_URI }, + }, + sessionId, + ), + ); + expect(read.status, "resources/read answers").toBe(200); + + // The whole regression is "the final frame never arrives": bound the read + // so a hang fails the test instead of wedging the suite. + const messages = yield* Effect.promise(() => readSseMessages(read)).pipe( + Effect.timeoutOrElse({ + duration: "60 seconds", + orElse: () => Effect.dieMessage("shell read hung: the response frame never arrived"), + }), + ); + + const response = messages.find((message) => message.id === "read-shell"); + expect(response, "the read's response frame arrived on the POST stream").toBeDefined(); + const result = response?.result as + | { readonly contents?: ReadonlyArray<{ readonly text?: string }> } + | undefined; + const html = result?.contents?.[0]?.text ?? ""; + expect( + html.length, + "the document is larger than the DO storage cap, so it took the live-only path", + ).toBeGreaterThan(DO_VALUE_CAP_BYTES); + expect(html, "and it is the real shell document").toContain('name="executor-mcp-apps-shell"'); + }), +); diff --git a/packages/hosts/cloudflare/src/mcp/agents-event-store.test.ts b/packages/hosts/cloudflare/src/mcp/agents-event-store.test.ts index 4430e92be..e97bbbbfe 100644 --- a/packages/hosts/cloudflare/src/mcp/agents-event-store.test.ts +++ b/packages/hosts/cloudflare/src/mcp/agents-event-store.test.ts @@ -62,7 +62,13 @@ describe("DurableObjectEventStore trimStream", () => { vi.restoreAllMocks(); }); - it("keeps an oversize final response instead of evicting the only event", async () => { + it("skips storing a message beyond DO storage's per-value cap, returning no replay id", async () => { + // Real DO storage rejects any value over 128 KiB. This used to be + // discovered inside storage.put — thrown BEFORE the live SSE write, so an + // oversize response (the ~5MB ui:// shell document) was neither stored nor + // delivered and the client hung on keepalives. The pinned contract now: + // oversize messages skip persistence with a warning and resolve undefined, + // and the caller delivers them live without a replay id. const { storage, store } = makeStore(); const hugeResult = { jsonrpc: "2.0" as const, @@ -72,33 +78,56 @@ describe("DurableObjectEventStore trimStream", () => { const eventId = await store.storeEvent("post-stream", hugeResult); - expect(eventId, "storeEvent returns the event id").toBe("post-stream:0000000000000001"); - expect(eventKeys(storage), "the >2MB final response survives its own trim pass").toEqual([ - "__mcp_event__:post-stream:0000000000000001", - ]); - expect(warnings, "no eviction happened, so no eviction warning").toEqual([]); + expect(eventId, "no replay id for an unstorable message").toBeUndefined(); + expect(eventKeys(storage), "nothing was persisted").toEqual([]); + expect(warnings.length, "the skip is logged").toBeGreaterThan(0); + expect(warnings.at(-1)).toContain("mcp_event_store_skipped_oversize"); + expect(warnings.at(-1)).toContain("post-stream"); + }); + + it("does not advance the stream sequence when an oversize message is skipped", async () => { + const { storage, store } = makeStore(); + await store.storeEvent("post-stream", { + jsonrpc: "2.0" as const, + method: "notifications/progress", + params: { blob: "x".repeat(3 * 1024 * 1024) }, + }); + + const eventId = await store.storeEvent("post-stream", { + jsonrpc: "2.0" as const, + id: 1, + result: { ok: true }, + }); + + expect(eventId, "the next storable event takes the first sequence slot").toBe( + "post-stream:0000000000000001", + ); + expect(eventKeys(storage)).toEqual(["__mcp_event__:post-stream:0000000000000001"]); }); it("evicts oldest events at the byte cap but never the newest", async () => { const { storage, store } = makeStore(); + // 100 KiB each: storable (under the 120 KiB per-value guard), but 25 of + // them exceed the 2 MB per-stream byte cap. const bigMessage = (marker: string) => ({ jsonrpc: "2.0" as const, method: "notifications/progress", - params: { marker, blob: "y".repeat(1024 * 1024) }, + params: { marker, blob: "y".repeat(100 * 1024) }, }); - await store.storeEvent("post-stream", bigMessage("one")); - await store.storeEvent("post-stream", bigMessage("two")); - await store.storeEvent("post-stream", bigMessage("three")); + const total = 25; + for (let index = 0; index < total; index += 1) { + await store.storeEvent("post-stream", bigMessage(`msg-${index}`)); + } const remaining = eventKeys(storage); expect(remaining[remaining.length - 1], "the newest event is always retained").toBe( - "__mcp_event__:post-stream:0000000000000003", + `__mcp_event__:post-stream:${total.toString(16).padStart(16, "0")}`, ); expect( remaining.length, "older events were evicted to satisfy the 2MB stream cap", - ).toBeLessThan(3); + ).toBeLessThan(total); expect(warnings.length, "eviction logs a warning").toBeGreaterThan(0); expect(warnings.at(-1)).toContain("mcp_event_store_evicted"); expect(warnings.at(-1)).toContain("post-stream"); diff --git a/packages/hosts/cloudflare/src/mcp/agents-priming-event.test.ts b/packages/hosts/cloudflare/src/mcp/agents-priming-event.test.ts index 12b479473..9edd16594 100644 --- a/packages/hosts/cloudflare/src/mcp/agents-priming-event.test.ts +++ b/packages/hosts/cloudflare/src/mcp/agents-priming-event.test.ts @@ -74,7 +74,10 @@ describe("POST-stream priming event: store ordering and replay", () => { const store = new DurableObjectEventStore(storage as never); const streamId = "post-stream"; - // Transport order: priming event first, then the tool response. + // Transport order: priming event first, then the tool response. Both are + // tiny, so storeEvent always persists them and returns an id — the + // `undefined` arm of its signature is the oversize skip, pinned in + // agents-event-store.test.ts. const primingId = await store.storeEvent(streamId, PRIMING_MESSAGE); const response = { jsonrpc: "2.0" as const, @@ -87,10 +90,13 @@ describe("POST-stream priming event: store ordering and replay", () => { expect(responseId, "response is seq 2, after the priming id").toBe( `${streamId}:0000000000000002`, ); - expect(primingId < responseId, "priming id sorts strictly before the response id").toBe(true); + expect( + (primingId ?? "") < (responseId ?? ""), + "priming id sorts strictly before the response id", + ).toBe(true); const replayed: Array<{ readonly eventId: string; readonly message: unknown }> = []; - await store.replayEventsAfter(primingId, { + await store.replayEventsAfter(primingId ?? "", { send: async (eventId: string, message: unknown) => { replayed.push({ eventId, message }); }, @@ -108,9 +114,10 @@ describe("POST-stream priming event: store ordering and replay", () => { const store = new DurableObjectEventStore(storage as never); const streamId = "post-stream"; const primingId = await store.storeEvent(streamId, PRIMING_MESSAGE); + expect(primingId, "a tiny priming message always persists and gets an id").toBeDefined(); const replayed: string[] = []; - await store.replayEventsAfter(primingId, { + await store.replayEventsAfter(primingId ?? "", { send: async (eventId: string) => { replayed.push(eventId); }, diff --git a/patches/agents@0.17.3.patch b/patches/agents@0.17.3.patch index abc60ab2a..df7396f95 100644 --- a/patches/agents@0.17.3.patch +++ b/patches/agents@0.17.3.patch @@ -1,3 +1,22 @@ +diff --git a/node_modules/agents/.bun-tag-c0c639aa2299e502 b/.bun-tag-c0c639aa2299e502 +new file mode 100644 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +diff --git a/dist/agent-tool-types-CNyE1iz_.d.ts b/dist/agent-tool-types-CNyE1iz_.d.ts +index 571eececebd5a1eaf7f2fbf5278801c4d34728ba..317e526489fcd3fd477a50525da0df666253bf0b 100644 +--- a/dist/agent-tool-types-CNyE1iz_.d.ts ++++ b/dist/agent-tool-types-CNyE1iz_.d.ts +@@ -480,7 +480,10 @@ declare class DurableObjectEventStore implements EventStore { + private readonly seqByStream; + private readonly seqInit; + constructor(storage: DurableObjectStorage); +- storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise; ++ /** Resolves `undefined` for a message too large for DO storage's 128 KiB ++ * per-value cap: the event is delivered live without a replay id rather ++ * than failing the send. */ ++ storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise; + getStreamIdForEventId(eventId: EventId): Promise; + replayEventsAfter( + lastEventId: EventId, diff --git a/dist/mcp/index.d.ts b/dist/mcp/index.d.ts index c8fad448e8797b89690a99d93490d1363851b225..77f9fe3f6f2375eadc9f7a2974f0d202fe75b3bd 100644 --- a/dist/mcp/index.d.ts @@ -19,7 +38,7 @@ index c8fad448e8797b89690a99d93490d1363851b225..77f9fe3f6f2375eadc9f7a2974f0d202 McpAgent, type McpAuthContext, diff --git a/dist/mcp/index.js b/dist/mcp/index.js -index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..32d52598b42a4062d09020f0a1abfd67dbf3c266 100644 +index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..444312ff41fbcff60e2ea681a9d5c756cd72faef 100644 --- a/dist/mcp/index.js +++ b/dist/mcp/index.js @@ -28,13 +28,17 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/ @@ -666,7 +685,31 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..32d52598b42a4062d09020f0a1abfd67 for (const message of messages) { if (this.messageInterceptor) { if (await this.messageInterceptor(message, { -@@ -777,9 +1136,11 @@ var StreamableHTTPServerTransport = class { +@@ -760,7 +1119,22 @@ var StreamableHTTPServerTransport = class { + * when the originating WS has dropped. + */ + async sendOnStream(agent, streamId, relatedIds, liveConnection, message, requestId) { +- const eventId = await this._eventStore?.storeEvent(streamId, message); ++ // Persistence is best-effort and must never block live delivery: a ++ // storeEvent failure (storage cap, storage outage) used to throw here, ++ // before writeSSEEvent, so the response was neither stored NOR sent and ++ // the client hung on keepalives. Deliver-live-first; a message without ++ // an eventId just isn't replayable after a drop. ++ let eventId; ++ try { ++ eventId = await this._eventStore?.storeEvent(streamId, message); ++ } catch (error) { ++ console.warn(JSON.stringify({ ++ event: "mcp_event_store_put_failed", ++ streamId, ++ error: String(error) ++ })); ++ this.onerror?.(error); ++ } + let shouldClose = false; + if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { + let responseIds = this._streamResponseIds.get(streamId); +@@ -777,9 +1151,11 @@ var StreamableHTTPServerTransport = class { } catch (error) { this.onerror?.(error); } @@ -680,7 +723,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..32d52598b42a4062d09020f0a1abfd67 } } async send(message, options) { -@@ -861,12 +1222,10 @@ var StreamableHTTPServerTransport = class { +@@ -861,12 +1237,10 @@ var StreamableHTTPServerTransport = class { * * ## Lifecycle * @@ -697,7 +740,34 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..32d52598b42a4062d09020f0a1abfd67 * * Standalone GET stream events (`_GET_stream`) are *not* cleared * automatically; they accumulate for the lifetime of the DO. Bounded -@@ -899,6 +1258,7 @@ var DurableObjectEventStore = class DurableObjectEventStore { +@@ -893,12 +1267,34 @@ var DurableObjectEventStore = class DurableObjectEventStore { + } + async storeEvent(streamId, message) { + if (streamId.includes(":")) throw new Error(`DurableObjectEventStore: streamId must not contain ':' (got ${JSON.stringify(streamId)})`); ++ // DO storage caps each value at 128 KiB; storage.put of a larger ++ // message throws, and before this guard that throw escaped through ++ // sendOnStream BEFORE the live SSE write, so an oversize response ++ // (e.g. the ~5MB ui:// shell document) was never delivered at all — ++ // the client saw only keepalives. Skip persistence instead: the ++ // event is delivered live without a replay id, which is strictly ++ // better than never delivering it. Undeliverable-if-dropped is the ++ // documented cost, logged so it is visible. ++ let messageBytes = 0; ++ try { ++ messageBytes = new TextEncoder().encode(JSON.stringify(message)).byteLength; ++ } catch {} ++ if (messageBytes > DurableObjectEventStore.MAX_STORABLE_EVENT_BYTES) { ++ console.warn(JSON.stringify({ ++ event: "mcp_event_store_skipped_oversize", ++ streamId, ++ messageBytes, ++ limit: DurableObjectEventStore.MAX_STORABLE_EVENT_BYTES ++ })); ++ return void 0; ++ } + await this.ensureSeqLoaded(streamId); + const seq = (this.seqByStream.get(streamId) ?? 0) + 1; + this.seqByStream.set(streamId, seq); const eventId = `${streamId}:${seq.toString(16).padStart(DurableObjectEventStore.SEQ_PAD, "0")}`; const eventKey = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${eventId}`; await this.storage.put(eventKey, message); @@ -705,7 +775,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..32d52598b42a4062d09020f0a1abfd67 return eventId; } async getStreamIdForEventId(eventId) { -@@ -915,9 +1275,59 @@ var DurableObjectEventStore = class DurableObjectEventStore { +@@ -915,9 +1311,59 @@ var DurableObjectEventStore = class DurableObjectEventStore { start: startKey, limit: DurableObjectEventStore.REPLAY_LIMIT }); @@ -713,8 +783,8 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..32d52598b42a4062d09020f0a1abfd67 + for (const [key, message] of rows) try { + await send(key.slice(DurableObjectEventStore.EVENT_KEY_PREFIX.length), message); + } catch {} - return streamId; - } ++ return streamId; ++ } + async replayEventsForStream(streamId, { send }) { + const prefix = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${streamId}:`; + const rows = await this.storage.list({ @@ -724,8 +794,8 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..32d52598b42a4062d09020f0a1abfd67 + for (const [key, message] of rows) try { + await send(key.slice(DurableObjectEventStore.EVENT_KEY_PREFIX.length), message); + } catch {} -+ return streamId; -+ } + return streamId; + } + async trimStream(streamId) { + const prefix = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${streamId}:`; + const rows = await this.storage.list({ @@ -766,17 +836,21 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..32d52598b42a4062d09020f0a1abfd67 /** * Drop the event log for a single stream. Called by the transport * immediately after a POST's final response has been written to the -@@ -973,6 +1383,9 @@ DurableObjectEventStore.EVENT_KEY_PREFIX = "__mcp_event__:"; +@@ -973,6 +1419,13 @@ DurableObjectEventStore.EVENT_KEY_PREFIX = "__mcp_event__:"; DurableObjectEventStore.SEQ_PAD = 16; DurableObjectEventStore.DELETE_CHUNK = 128; DurableObjectEventStore.REPLAY_LIMIT = 1e3; +DurableObjectEventStore.MAX_EVENTS_PER_STREAM = 64; +DurableObjectEventStore.MAX_BYTES_PER_STREAM = 2 * 1024 * 1024; +DurableObjectEventStore.MAX_EVENT_BYTES = 2 * 1024 * 1024; ++// DO storage's per-value hard cap is 128 KiB. The JSON byte length measured in ++// storeEvent is a close proxy for the runtime's serialized size; the margin ++// below it absorbs the difference. Anything larger is delivered live only. ++DurableObjectEventStore.MAX_STORABLE_EVENT_BYTES = 120 * 1024; //#endregion //#region src/mcp/client-transports.ts /** -@@ -1381,6 +1794,47 @@ var McpAgent = class McpAgent extends Agent { +@@ -1381,6 +1834,47 @@ var McpAgent = class McpAgent extends Agent { async deleteStreamRequestIds(streamId) { await this.ctx.storage.delete(`${McpAgent.STREAM_REQS_KEY_PREFIX}${streamId}`); } @@ -824,7 +898,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..32d52598b42a4062d09020f0a1abfd67 /** * Reverse lookup: find which POST stream a given `requestId` belongs * to, and return the stream's full `requestIds` list in the same -@@ -1697,7 +2151,8 @@ var McpAgent = class McpAgent extends Agent { +@@ -1697,7 +2191,8 @@ var McpAgent = class McpAgent extends Agent { } }; McpAgent.STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:"; From 8a0d00efb838ad85e91ea0dd0b2ba1adf3b2f20b Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:30:45 -0700 Subject: [PATCH 2/2] Fix e2e typecheck: Effect.die instead of the unavailable dieMessage --- e2e/cloud/mcp-oversize-response.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/cloud/mcp-oversize-response.test.ts b/e2e/cloud/mcp-oversize-response.test.ts index 83b72e2de..c510e8c74 100644 --- a/e2e/cloud/mcp-oversize-response.test.ts +++ b/e2e/cloud/mcp-oversize-response.test.ts @@ -117,7 +117,7 @@ scenario( const messages = yield* Effect.promise(() => readSseMessages(read)).pipe( Effect.timeoutOrElse({ duration: "60 seconds", - orElse: () => Effect.dieMessage("shell read hung: the response frame never arrived"), + orElse: () => Effect.die(new Error("shell read hung: the response frame never arrived")), }), );