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.
Unified · Split
| @@ -0,0 +1,7 @@ | ||
| 1 | +--- | |
| 2 | +"executor": patch | |
| 3 | +--- | |
| 4 | + | |
| 5 | +**Fix: MCP responses larger than Durable Object storage's 128 KiB per-value cap hung the client instead of being delivered** | |
| 6 | + | |
| 7 | +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. | |
| @@ -0,0 +1,136 @@ | ||
| 1 | +// Cloud: an MCP response bigger than DO storage's 128 KiB per-value cap is | |
| 2 | +// still delivered. The ui://executor/shell.html resource (~5 MB of inlined | |
| 3 | +// shell document) is the production case: the transport used to persist every | |
| 4 | +// outbound message with storage.put BEFORE writing the live SSE frame, so the | |
| 5 | +// put's failure killed the send and the client saw keepalives forever — every | |
| 6 | +// artifact widget in every remote MCP client hung, with no error anywhere | |
| 7 | +// (found live in prod, 2026-07-29). The patched transport delivers live first | |
| 8 | +// and treats persistence as best-effort, so this pins the whole path over the | |
| 9 | +// real workerd topology: POST resources/read → McpSessionDO → ASSETS-backed | |
| 10 | +// shell loader → event store skip (oversize) → live SSE frame → this client. | |
| 11 | +import { expect } from "@effect/vitest"; | |
| 12 | +import { Effect } from "effect"; | |
| 13 | + | |
| 14 | +import { scenario } from "../src/scenario"; | |
| 15 | +import { Mcp, Target } from "../src/services"; | |
| 16 | +import type { Identity } from "../src/target"; | |
| 17 | + | |
| 18 | +const PROTOCOL_VERSION = "2025-03-26"; | |
| 19 | +const JSON_AND_SSE = "application/json, text/event-stream"; | |
| 20 | + | |
| 21 | +const SHELL_RESOURCE_URI = "ui://executor/shell.html"; | |
| 22 | + | |
| 23 | +const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label; | |
| 24 | +// DO storage's per-value cap; anything beyond it exercises the skip path. | |
| 25 | +const DO_VALUE_CAP_BYTES = 128 * 1024; | |
| 26 | + | |
| 27 | +const mcpHeaders = (bearer: string, sessionId?: string) => ({ | |
| 28 | + accept: JSON_AND_SSE, | |
| 29 | + authorization: `Bearer ${bearer}`, | |
| 30 | + "content-type": "application/json", | |
| 31 | + "mcp-protocol-version": PROTOCOL_VERSION, | |
| 32 | + ...(sessionId ? { "mcp-session-id": sessionId } : {}), | |
| 33 | +}); | |
| 34 | + | |
| 35 | +const postJson = (mcpUrl: string, bearer: string, body: unknown, sessionId?: string) => | |
| 36 | + fetch(mcpUrl, { | |
| 37 | + method: "POST", | |
| 38 | + headers: mcpHeaders(bearer, sessionId), | |
| 39 | + body: JSON.stringify(body), | |
| 40 | + }); | |
| 41 | + | |
| 42 | +const openSession = async (mcpUrl: string, bearer: string): Promise<string> => { | |
| 43 | + const initialized = await postJson(mcpUrl, bearer, { | |
| 44 | + jsonrpc: "2.0" as const, | |
| 45 | + id: "initialize", | |
| 46 | + method: "initialize", | |
| 47 | + params: { | |
| 48 | + protocolVersion: PROTOCOL_VERSION, | |
| 49 | + capabilities: {}, | |
| 50 | + clientInfo: { name: "executor-e2e-oversize-response", version: "0.0.1" }, | |
| 51 | + }, | |
| 52 | + }); | |
| 53 | + const sessionId = initialized.headers.get("mcp-session-id"); | |
| 54 | + await initialized.text(); | |
| 55 | + if (initialized.status !== 200 || !sessionId) { | |
| 56 | + throw new Error(`openSession: initialize failed (${initialized.status})`); | |
| 57 | + } | |
| 58 | + const notification = await postJson( | |
| 59 | + mcpUrl, | |
| 60 | + bearer, | |
| 61 | + { jsonrpc: "2.0" as const, method: "notifications/initialized" }, | |
| 62 | + sessionId, | |
| 63 | + ); | |
| 64 | + await notification.text(); | |
| 65 | + expect(notification.status, "notifications/initialized is accepted").toBe(202); | |
| 66 | + return sessionId; | |
| 67 | +}; | |
| 68 | + | |
| 69 | +/** Read the POST's SSE body to completion and return the JSON-RPC payloads. */ | |
| 70 | +const readSseMessages = async ( | |
| 71 | + response: Response, | |
| 72 | +): Promise<ReadonlyArray<{ readonly id?: unknown; readonly result?: unknown }>> => { | |
| 73 | + const text = await response.text(); | |
| 74 | + const messages: Array<{ readonly id?: unknown; readonly result?: unknown }> = []; | |
| 75 | + for (const block of text.replace(/\r/g, "").split("\n\n")) { | |
| 76 | + const data = block | |
| 77 | + .split("\n") | |
| 78 | + .filter((line) => line.startsWith("data:")) | |
| 79 | + .map((line) => line.slice("data:".length).trimStart()) | |
| 80 | + .join("\n") | |
| 81 | + .trim(); | |
| 82 | + if (data) messages.push(JSON.parse(data) as { readonly id?: unknown }); | |
| 83 | + } | |
| 84 | + return messages; | |
| 85 | +}; | |
| 86 | + | |
| 87 | +scenario( | |
| 88 | + "MCP streamable HTTP · a response beyond the DO storage value cap is delivered live (the ui:// shell resource)", | |
| 89 | + {}, | |
| 90 | + Effect.gen(function* () { | |
| 91 | + const target = yield* Target; | |
| 92 | + const mcp = yield* Mcp; | |
| 93 | + const identity = yield* target.newIdentity(); | |
| 94 | + const bearer = yield* mcp.mintBearer(emailOf(identity)); | |
| 95 | + // Artifacts opt-in is what registers the shell resource on the session. | |
| 96 | + const mcpUrl = `${target.mcpUrl}?artifacts=true`; | |
| 97 | + | |
| 98 | + const sessionId = yield* Effect.promise(() => openSession(mcpUrl, bearer)); | |
| 99 | + | |
| 100 | + const read = yield* Effect.promise(() => | |
| 101 | + postJson( | |
| 102 | + mcpUrl, | |
| 103 | + bearer, | |
| 104 | + { | |
| 105 | + jsonrpc: "2.0" as const, | |
| 106 | + id: "read-shell", | |
| 107 | + method: "resources/read", | |
| 108 | + params: { uri: SHELL_RESOURCE_URI }, | |
| 109 | + }, | |
| 110 | + sessionId, | |
| 111 | + ), | |
| 112 | + ); | |
| 113 | + expect(read.status, "resources/read answers").toBe(200); | |
| 114 | + | |
| 115 | + // The whole regression is "the final frame never arrives": bound the read | |
| 116 | + // so a hang fails the test instead of wedging the suite. | |
| 117 | + const messages = yield* Effect.promise(() => readSseMessages(read)).pipe( | |
| 118 | + Effect.timeoutOrElse({ | |
| 119 | + duration: "60 seconds", | |
| 120 | + orElse: () => Effect.dieMessage("shell read hung: the response frame never arrived"), | |
| 121 | + }), | |
| 122 | + ); | |
| 123 | + | |
| 124 | + const response = messages.find((message) => message.id === "read-shell"); | |
| 125 | + expect(response, "the read's response frame arrived on the POST stream").toBeDefined(); | |
| 126 | + const result = response?.result as | |
| 127 | + | { readonly contents?: ReadonlyArray<{ readonly text?: string }> } | |
| 128 | + | undefined; | |
| 129 | + const html = result?.contents?.[0]?.text ?? ""; | |
| 130 | + expect( | |
| 131 | + html.length, | |
| 132 | + "the document is larger than the DO storage cap, so it took the live-only path", | |
| 133 | + ).toBeGreaterThan(DO_VALUE_CAP_BYTES); | |
| 134 | + expect(html, "and it is the real shell document").toContain('name="executor-mcp-apps-shell"'); | |
| 135 | + }), | |
| 136 | +); | |
| @@ -62,7 +62,13 @@ describe("DurableObjectEventStore trimStream", () => { · 61 unmodified lines | ||
| 62 | 62 | vi.restoreAllMocks(); |
| 63 | 63 | }); |
| 64 | 64 | |
| 65 | − it("keeps an oversize final response instead of evicting the only event", async () => { | |
| 65 | + it("skips storing a message beyond DO storage's per-value cap, returning no replay id", async () => { | |
| 66 | + // Real DO storage rejects any value over 128 KiB. This used to be | |
| 67 | + // discovered inside storage.put — thrown BEFORE the live SSE write, so an | |
| 68 | + // oversize response (the ~5MB ui:// shell document) was neither stored nor | |
| 69 | + // delivered and the client hung on keepalives. The pinned contract now: | |
| 70 | + // oversize messages skip persistence with a warning and resolve undefined, | |
| 71 | + // and the caller delivers them live without a replay id. | |
| 66 | 72 | const { storage, store } = makeStore(); |
| 67 | 73 | const hugeResult = { |
| 68 | 74 | jsonrpc: "2.0" as const, |
| @@ -72,33 +78,56 @@ describe("DurableObjectEventStore trimStream", () => { · 3 unmodified lines | ||
| 72 | 78 | |
| 73 | 79 | const eventId = await store.storeEvent("post-stream", hugeResult); |
| 74 | 80 | |
| 75 | − expect(eventId, "storeEvent returns the event id").toBe("post-stream:0000000000000001"); | |
| 76 | − expect(eventKeys(storage), "the >2MB final response survives its own trim pass").toEqual([ | |
| 77 | − "__mcp_event__:post-stream:0000000000000001", | |
| 78 | − ]); | |
| 79 | − expect(warnings, "no eviction happened, so no eviction warning").toEqual([]); | |
| 81 | + expect(eventId, "no replay id for an unstorable message").toBeUndefined(); | |
| 82 | + expect(eventKeys(storage), "nothing was persisted").toEqual([]); | |
| 83 | + expect(warnings.length, "the skip is logged").toBeGreaterThan(0); | |
| 84 | + expect(warnings.at(-1)).toContain("mcp_event_store_skipped_oversize"); | |
| 85 | + expect(warnings.at(-1)).toContain("post-stream"); | |
| 86 | + }); | |
| 87 | + | |
| 88 | + it("does not advance the stream sequence when an oversize message is skipped", async () => { | |
| 89 | + const { storage, store } = makeStore(); | |
| 90 | + await store.storeEvent("post-stream", { | |
| 91 | + jsonrpc: "2.0" as const, | |
| 92 | + method: "notifications/progress", | |
| 93 | + params: { blob: "x".repeat(3 * 1024 * 1024) }, | |
| 94 | + }); | |
| 95 | + | |
| 96 | + const eventId = await store.storeEvent("post-stream", { | |
| 97 | + jsonrpc: "2.0" as const, | |
| 98 | + id: 1, | |
| 99 | + result: { ok: true }, | |
| 100 | + }); | |
| 101 | + | |
| 102 | + expect(eventId, "the next storable event takes the first sequence slot").toBe( | |
| 103 | + "post-stream:0000000000000001", | |
| 104 | + ); | |
| 105 | + expect(eventKeys(storage)).toEqual(["__mcp_event__:post-stream:0000000000000001"]); | |
| 80 | 106 | }); |
| 81 | 107 | |
| 82 | 108 | it("evicts oldest events at the byte cap but never the newest", async () => { |
| 83 | 109 | const { storage, store } = makeStore(); |
| 110 | + // 100 KiB each: storable (under the 120 KiB per-value guard), but 25 of | |
| 111 | + // them exceed the 2 MB per-stream byte cap. | |
| 84 | 112 | const bigMessage = (marker: string) => ({ |
| 85 | 113 | jsonrpc: "2.0" as const, |
| 86 | 114 | method: "notifications/progress", |
| 87 | − params: { marker, blob: "y".repeat(1024 * 1024) }, | |
| 115 | + params: { marker, blob: "y".repeat(100 * 1024) }, | |
| 88 | 116 | }); |
| 89 | 117 | |
| 90 | − await store.storeEvent("post-stream", bigMessage("one")); | |
| 91 | − await store.storeEvent("post-stream", bigMessage("two")); | |
| 92 | − await store.storeEvent("post-stream", bigMessage("three")); | |
| 118 | + const total = 25; | |
| 119 | + for (let index = 0; index < total; index += 1) { | |
| 120 | + await store.storeEvent("post-stream", bigMessage(`msg-${index}`)); | |
| 121 | + } | |
| 93 | 122 | |
| 94 | 123 | const remaining = eventKeys(storage); |
| 95 | 124 | expect(remaining[remaining.length - 1], "the newest event is always retained").toBe( |
| 96 | − "__mcp_event__:post-stream:0000000000000003", | |
| 125 | + `__mcp_event__:post-stream:${total.toString(16).padStart(16, "0")}`, | |
| 97 | 126 | ); |
| 98 | 127 | expect( |
| 99 | 128 | remaining.length, |
| 100 | 129 | "older events were evicted to satisfy the 2MB stream cap", |
| 101 | − ).toBeLessThan(3); | |
| 130 | + ).toBeLessThan(total); | |
| 102 | 131 | expect(warnings.length, "eviction logs a warning").toBeGreaterThan(0); |
| 103 | 132 | expect(warnings.at(-1)).toContain("mcp_event_store_evicted"); |
| 104 | 133 | expect(warnings.at(-1)).toContain("post-stream"); |
| @@ -74,7 +74,10 @@ describe("POST-stream priming event: store ordering and replay", () => { · 73 unmodified lines | ||
| 74 | 74 | const store = new DurableObjectEventStore(storage as never); |
| 75 | 75 | const streamId = "post-stream"; |
| 76 | 76 | |
| 77 | − // Transport order: priming event first, then the tool response. | |
| 77 | + // Transport order: priming event first, then the tool response. Both are | |
| 78 | + // tiny, so storeEvent always persists them and returns an id — the | |
| 79 | + // `undefined` arm of its signature is the oversize skip, pinned in | |
| 80 | + // agents-event-store.test.ts. | |
| 78 | 81 | const primingId = await store.storeEvent(streamId, PRIMING_MESSAGE); |
| 79 | 82 | const response = { |
| 80 | 83 | jsonrpc: "2.0" as const, |
| @@ -87,10 +90,13 @@ describe("POST-stream priming event: store ordering and replay", () => { · 6 unmodified lines | ||
| 87 | 90 | expect(responseId, "response is seq 2, after the priming id").toBe( |
| 88 | 91 | `${streamId}:0000000000000002`, |
| 89 | 92 | ); |
| 90 | − expect(primingId < responseId, "priming id sorts strictly before the response id").toBe(true); | |
| 93 | + expect( | |
| 94 | + (primingId ?? "") < (responseId ?? ""), | |
| 95 | + "priming id sorts strictly before the response id", | |
| 96 | + ).toBe(true); | |
| 91 | 97 | |
| 92 | 98 | const replayed: Array<{ readonly eventId: string; readonly message: unknown }> = []; |
| 93 | − await store.replayEventsAfter(primingId, { | |
| 99 | + await store.replayEventsAfter(primingId ?? "", { | |
| 94 | 100 | send: async (eventId: string, message: unknown) => { |
| 95 | 101 | replayed.push({ eventId, message }); |
| 96 | 102 | }, |
| @@ -108,9 +114,10 @@ describe("POST-stream priming event: store ordering and replay", () => { · 11 unmodified lines | ||
| 108 | 114 | const store = new DurableObjectEventStore(storage as never); |
| 109 | 115 | const streamId = "post-stream"; |
| 110 | 116 | const primingId = await store.storeEvent(streamId, PRIMING_MESSAGE); |
| 117 | + expect(primingId, "a tiny priming message always persists and gets an id").toBeDefined(); | |
| 111 | 118 | |
| 112 | 119 | const replayed: string[] = []; |
| 113 | − await store.replayEventsAfter(primingId, { | |
| 120 | + await store.replayEventsAfter(primingId ?? "", { | |
| 114 | 121 | send: async (eventId: string) => { |
| 115 | 122 | replayed.push(eventId); |
| 116 | 123 | }, |
| @@ -1,3 +1,22 @@ | ||
| 1 | +diff --git a/node_modules/agents/.bun-tag-c0c639aa2299e502 b/.bun-tag-c0c639aa2299e502 | |
| 2 | +new file mode 100644 | |
| 3 | +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 | |
| 4 | +diff --git a/dist/agent-tool-types-CNyE1iz_.d.ts b/dist/agent-tool-types-CNyE1iz_.d.ts | |
| 5 | +index 571eececebd5a1eaf7f2fbf5278801c4d34728ba..317e526489fcd3fd477a50525da0df666253bf0b 100644 | |
| 6 | +--- a/dist/agent-tool-types-CNyE1iz_.d.ts | |
| 7 | ++++ b/dist/agent-tool-types-CNyE1iz_.d.ts | |
| 8 | +@@ -480,7 +480,10 @@ declare class DurableObjectEventStore implements EventStore { | |
| 9 | + private readonly seqByStream; | |
| 10 | + private readonly seqInit; | |
| 11 | + constructor(storage: DurableObjectStorage); | |
| 12 | +- storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId>; | |
| 13 | ++ /** Resolves `undefined` for a message too large for DO storage's 128 KiB | |
| 14 | ++ * per-value cap: the event is delivered live without a replay id rather | |
| 15 | ++ * than failing the send. */ | |
| 16 | ++ storeEvent(streamId: StreamId, message: JSONRPCMessage): Promise<EventId | undefined>; | |
| 17 | + getStreamIdForEventId(eventId: EventId): Promise<StreamId | undefined>; | |
| 18 | + replayEventsAfter( | |
| 19 | + lastEventId: EventId, | |
| 1 | 20 | diff --git a/dist/mcp/index.d.ts b/dist/mcp/index.d.ts |
| 2 | 21 | index c8fad448e8797b89690a99d93490d1363851b225..77f9fe3f6f2375eadc9f7a2974f0d202fe75b3bd 100644 |
| 3 | 22 | --- a/dist/mcp/index.d.ts |
| @@ -19,7 +38,7 @@ index c8fad448e8797b89690a99d93490d1363851b225..77f9fe3f6f2375eadc9f7a2974f0d202 · 15 unmodified lines | ||
| 19 | 38 | McpAgent, |
| 20 | 39 | type McpAuthContext, |
| 21 | 40 | diff --git a/dist/mcp/index.js b/dist/mcp/index.js |
| 22 | −index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..32d52598b42a4062d09020f0a1abfd67dbf3c266 100644 | |
| 41 | +index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..444312ff41fbcff60e2ea681a9d5c756cd72faef 100644 | |
| 23 | 42 | --- a/dist/mcp/index.js |
| 24 | 43 | +++ b/dist/mcp/index.js |
| 25 | 44 | @@ -28,13 +28,17 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/ |
| @@ -666,7 +685,31 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..32d52598b42a4062d09020f0a1abfd67 · 640 unmodified lines | ||
| 666 | 685 | for (const message of messages) { |
| 667 | 686 | if (this.messageInterceptor) { |
| 668 | 687 | if (await this.messageInterceptor(message, { |
| 669 | −@@ -777,9 +1136,11 @@ var StreamableHTTPServerTransport = class { | |
| 688 | +@@ -760,7 +1119,22 @@ var StreamableHTTPServerTransport = class { | |
| 689 | + * when the originating WS has dropped. | |
| 690 | + */ | |
| 691 | + async sendOnStream(agent, streamId, relatedIds, liveConnection, message, requestId) { | |
| 692 | +- const eventId = await this._eventStore?.storeEvent(streamId, message); | |
| 693 | ++ // Persistence is best-effort and must never block live delivery: a | |
| 694 | ++ // storeEvent failure (storage cap, storage outage) used to throw here, | |
| 695 | ++ // before writeSSEEvent, so the response was neither stored NOR sent and | |
| 696 | ++ // the client hung on keepalives. Deliver-live-first; a message without | |
| 697 | ++ // an eventId just isn't replayable after a drop. | |
| 698 | ++ let eventId; | |
| 699 | ++ try { | |
| 700 | ++ eventId = await this._eventStore?.storeEvent(streamId, message); | |
| 701 | ++ } catch (error) { | |
| 702 | ++ console.warn(JSON.stringify({ | |
| 703 | ++ event: "mcp_event_store_put_failed", | |
| 704 | ++ streamId, | |
| 705 | ++ error: String(error) | |
| 706 | ++ })); | |
| 707 | ++ this.onerror?.(error); | |
| 708 | ++ } | |
| 709 | + let shouldClose = false; | |
| 710 | + if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { | |
| 711 | + let responseIds = this._streamResponseIds.get(streamId); | |
| 712 | +@@ -777,9 +1151,11 @@ var StreamableHTTPServerTransport = class { | |
| 670 | 713 | } catch (error) { |
| 671 | 714 | this.onerror?.(error); |
| 672 | 715 | } |
| @@ -680,7 +723,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..32d52598b42a4062d09020f0a1abfd67 · 7 unmodified lines | ||
| 680 | 723 | } |
| 681 | 724 | } |
| 682 | 725 | async send(message, options) { |
| 683 | −@@ -861,12 +1222,10 @@ var StreamableHTTPServerTransport = class { | |
| 726 | +@@ -861,12 +1237,10 @@ var StreamableHTTPServerTransport = class { | |
| 684 | 727 | * |
| 685 | 728 | * ## Lifecycle |
| 686 | 729 | * |
| @@ -697,7 +740,34 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..32d52598b42a4062d09020f0a1abfd67 · 10 unmodified lines | ||
| 697 | 740 | * |
| 698 | 741 | * Standalone GET stream events (`_GET_stream`) are *not* cleared |
| 699 | 742 | * automatically; they accumulate for the lifetime of the DO. Bounded |
| 700 | −@@ -899,6 +1258,7 @@ var DurableObjectEventStore = class DurableObjectEventStore { | |
| 743 | +@@ -893,12 +1267,34 @@ var DurableObjectEventStore = class DurableObjectEventStore { | |
| 744 | + } | |
| 745 | + async storeEvent(streamId, message) { | |
| 746 | + if (streamId.includes(":")) throw new Error(`DurableObjectEventStore: streamId must not contain ':' (got ${JSON.stringify(streamId)})`); | |
| 747 | ++ // DO storage caps each value at 128 KiB; storage.put of a larger | |
| 748 | ++ // message throws, and before this guard that throw escaped through | |
| 749 | ++ // sendOnStream BEFORE the live SSE write, so an oversize response | |
| 750 | ++ // (e.g. the ~5MB ui:// shell document) was never delivered at all — | |
| 751 | ++ // the client saw only keepalives. Skip persistence instead: the | |
| 752 | ++ // event is delivered live without a replay id, which is strictly | |
| 753 | ++ // better than never delivering it. Undeliverable-if-dropped is the | |
| 754 | ++ // documented cost, logged so it is visible. | |
| 755 | ++ let messageBytes = 0; | |
| 756 | ++ try { | |
| 757 | ++ messageBytes = new TextEncoder().encode(JSON.stringify(message)).byteLength; | |
| 758 | ++ } catch {} | |
| 759 | ++ if (messageBytes > DurableObjectEventStore.MAX_STORABLE_EVENT_BYTES) { | |
| 760 | ++ console.warn(JSON.stringify({ | |
| 761 | ++ event: "mcp_event_store_skipped_oversize", | |
| 762 | ++ streamId, | |
| 763 | ++ messageBytes, | |
| 764 | ++ limit: DurableObjectEventStore.MAX_STORABLE_EVENT_BYTES | |
| 765 | ++ })); | |
| 766 | ++ return void 0; | |
| 767 | ++ } | |
| 768 | + await this.ensureSeqLoaded(streamId); | |
| 769 | + const seq = (this.seqByStream.get(streamId) ?? 0) + 1; | |
| 770 | + this.seqByStream.set(streamId, seq); | |
| 701 | 771 | const eventId = `${streamId}:${seq.toString(16).padStart(DurableObjectEventStore.SEQ_PAD, "0")}`; |
| 702 | 772 | const eventKey = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${eventId}`; |
| 703 | 773 | await this.storage.put(eventKey, message); |
| @@ -705,7 +775,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..32d52598b42a4062d09020f0a1abfd67 · 1 unmodified line | ||
| 705 | 775 | return eventId; |
| 706 | 776 | } |
| 707 | 777 | async getStreamIdForEventId(eventId) { |
| 708 | −@@ -915,9 +1275,59 @@ var DurableObjectEventStore = class DurableObjectEventStore { | |
| 778 | +@@ -915,9 +1311,59 @@ var DurableObjectEventStore = class DurableObjectEventStore { | |
| 709 | 779 | start: startKey, |
| 710 | 780 | limit: DurableObjectEventStore.REPLAY_LIMIT |
| 711 | 781 | }); |
| @@ -713,8 +783,8 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..32d52598b42a4062d09020f0a1abfd67 · 1 unmodified line | ||
| 713 | 783 | + for (const [key, message] of rows) try { |
| 714 | 784 | + await send(key.slice(DurableObjectEventStore.EVENT_KEY_PREFIX.length), message); |
| 715 | 785 | + } catch {} |
| 716 | − return streamId; | |
| 717 | − } | |
| 786 | ++ return streamId; | |
| 787 | ++ } | |
| 718 | 788 | + async replayEventsForStream(streamId, { send }) { |
| 719 | 789 | + const prefix = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${streamId}:`; |
| 720 | 790 | + const rows = await this.storage.list({ |
| @@ -724,8 +794,8 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..32d52598b42a4062d09020f0a1abfd67 · 3 unmodified lines | ||
| 724 | 794 | + for (const [key, message] of rows) try { |
| 725 | 795 | + await send(key.slice(DurableObjectEventStore.EVENT_KEY_PREFIX.length), message); |
| 726 | 796 | + } catch {} |
| 727 | −+ return streamId; | |
| 728 | −+ } | |
| 797 | + return streamId; | |
| 798 | + } | |
| 729 | 799 | + async trimStream(streamId) { |
| 730 | 800 | + const prefix = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${streamId}:`; |
| 731 | 801 | + const rows = await this.storage.list({ |
| @@ -766,17 +836,21 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..32d52598b42a4062d09020f0a1abfd67 · 34 unmodified lines | ||
| 766 | 836 | /** |
| 767 | 837 | * Drop the event log for a single stream. Called by the transport |
| 768 | 838 | * immediately after a POST's final response has been written to the |
| 769 | −@@ -973,6 +1383,9 @@ DurableObjectEventStore.EVENT_KEY_PREFIX = "__mcp_event__:"; | |
| 839 | +@@ -973,6 +1419,13 @@ DurableObjectEventStore.EVENT_KEY_PREFIX = "__mcp_event__:"; | |
| 770 | 840 | DurableObjectEventStore.SEQ_PAD = 16; |
| 771 | 841 | DurableObjectEventStore.DELETE_CHUNK = 128; |
| 772 | 842 | DurableObjectEventStore.REPLAY_LIMIT = 1e3; |
| 773 | 843 | +DurableObjectEventStore.MAX_EVENTS_PER_STREAM = 64; |
| 774 | 844 | +DurableObjectEventStore.MAX_BYTES_PER_STREAM = 2 * 1024 * 1024; |
| 775 | 845 | +DurableObjectEventStore.MAX_EVENT_BYTES = 2 * 1024 * 1024; |
| 846 | ++// DO storage's per-value hard cap is 128 KiB. The JSON byte length measured in | |
| 847 | ++// storeEvent is a close proxy for the runtime's serialized size; the margin | |
| 848 | ++// below it absorbs the difference. Anything larger is delivered live only. | |
| 849 | ++DurableObjectEventStore.MAX_STORABLE_EVENT_BYTES = 120 * 1024; | |
| 776 | 850 | //#endregion |
| 777 | 851 | //#region src/mcp/client-transports.ts |
| 778 | 852 | /** |
| 779 | −@@ -1381,6 +1794,47 @@ var McpAgent = class McpAgent extends Agent { | |
| 853 | +@@ -1381,6 +1834,47 @@ var McpAgent = class McpAgent extends Agent { | |
| 780 | 854 | async deleteStreamRequestIds(streamId) { |
| 781 | 855 | await this.ctx.storage.delete(`${McpAgent.STREAM_REQS_KEY_PREFIX}${streamId}`); |
| 782 | 856 | } |
| @@ -824,7 +898,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..32d52598b42a4062d09020f0a1abfd67 · 41 unmodified lines | ||
| 824 | 898 | /** |
| 825 | 899 | * Reverse lookup: find which POST stream a given `requestId` belongs |
| 826 | 900 | * to, and return the stream's full `requestIds` list in the same |
| 827 | −@@ -1697,7 +2151,8 @@ var McpAgent = class McpAgent extends Agent { | |
| 901 | +@@ -1697,7 +2191,8 @@ var McpAgent = class McpAgent extends Agent { | |
| 828 | 902 | } |
| 829 | 903 | }; |
| 830 | 904 | McpAgent.STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:"; |