History

Report executions lost to a session reset (#1978)

Rhys Sullivan committed 2 months ago

Commit f1d95f21 parent 6870f389 files · +984 −73Browse files

Unified · Split

9 files
  1. .changeset/lost-execution-visibility.md +5 −0
  2. apps/cloud/src/observability/observability.test.ts +12 −3
  3. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts +129 −2
  4. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +13 −7
  5. packages/hosts/cloudflare/src/mcp/agents-post-stream-loss.test.ts +337 −0
  6. packages/hosts/cloudflare/src/mcp/agents-sse-max-age.test.ts +8 −3
  7. packages/hosts/cloudflare/src/mcp/durable-object-errors.test.ts +14 −5
  8. packages/hosts/cloudflare/src/mcp/durable-object-errors.ts +37 −7
  9. patches/agents@0.17.3.patch +429 −46
.changeset/lost-execution-visibility.md+5−0
@@ -0,0 +1,5 @@
1+---
2+"executor": patch
3+---
4+
5+Report an MCP `execute` call that dies with a session reset as a JSON-RPC error instead of a silently closed stream. The front worker answers outstanding request ids when the session socket closes abnormally or a response deadline passes, and a rebuilt session answers ids stranded by a previous incarnation on the next stream. The plain memory-limit reset is now classified as transient.
apps/cloud/src/observability/observability.test.ts+12−3
@@ -385,15 +385,24 @@ describe("Durable Object platform reset noise", () => { · 384 unmodified lines
385385 expect(beforeSendWithOtelCorrelation(defect)).not.toBeNull();
386386 });
387387
388− // The memory-limit reset is deliberately absent from the classifier: the
389− // runtime blames the application for it, so it is a defect, not noise.
390− it("keeps the memory-limit reset the classifier deliberately excludes", () => {
388+ // The storage-cache memory-limit variant stays absent from the classifier:
389+ // the runtime blames the application for it (un-awaited writes, an oversized
390+ // read), so it is a defect, not noise. Its plain sibling is a platform reset
391+ // and IS classified — the two are separated only by that qualifier.
392+ it("keeps the memory-limit variant the classifier deliberately excludes", () => {
391393 const memory = doInstrumentationEvent(
392394 "Durable Object's isolate exceeded its memory limit due to overflowing the storage cache. All objects in the isolate were reset.",
393395 );
394396 expect(beforeSendWithOtelCorrelation(memory)).not.toBeNull();
395397 });
396398
399+ it("drops the plain memory-limit reset as platform noise", () => {
400+ const memory = doInstrumentationEvent(
401+ "Durable Object's isolate exceeded its memory limit and was reset.",
402+ );
403+ expect(beforeSendWithOtelCorrelation(memory)).toBeNull();
404+ });
405+
397406 it("the hook the worker and DOs install drops the deploy reset", () => {
398407 const options = cloudSentryOptions({ SENTRY_DSN: "https://public@example.invalid/1" } as Env);
399408 const event = doInstrumentationEvent("Durable Object reset because its code was updated.");
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts+129−2
@@ -153,6 +153,20 @@ type HarnessSession = { · 152 unmodified lines
153153 >;
154154 alarm: () => Promise<void>;
155155 ctx: MemoryStorage;
156+ currentSessionEpoch: () => Promise<number>;
157+ getStaleEpochStreamRequestIds: () => Promise<
158+ ReadonlyArray<{
159+ readonly streamId: string;
160+ readonly requestIds: ReadonlyArray<string | number>;
161+ readonly epoch: number;
162+ readonly currentEpoch: number;
163+ }>
164+ >;
165+ getStreamRequestIds: (streamId: string) => Promise<ReadonlyArray<string | number> | undefined>;
166+ setStreamRequestIds: (
167+ streamId: string,
168+ requestIds: ReadonlyArray<string | number>,
169+ ) => Promise<void>;
156170 dbHandle: { readonly end: () => void } | null;
157171 engine: ExecutionEngine<Cause.YieldableError> | null;
158172 getConnections?: () => Iterable<unknown>;
@@ -266,7 +280,14 @@ const approval = { · 107 unmodified lines
266280 content: { approved: true },
267281 } satisfies ResumeResponse;
268282
269−const makeHarnessSession = async (): Promise<HarnessSession> => {
283+/**
284+ * `storage` is a parameter so a test can build a SECOND session on the same
285+ * durable storage — that is exactly what a Durable Object reset looks like from
286+ * storage's point of view: same keys, brand new instance.
287+ */
288+const makeHarnessSession = async (
289+ storage: MemoryStorage = new MemoryStorage(),
290+): Promise<HarnessSession> => {
270291 const sessionId = "session-reconnect";
271292 const sessionMeta: SessionMeta = {
272293 organizationId: "org-1",
@@ -275,7 +296,6 @@ const makeHarnessSession = async (): Promise<HarnessSession> => { · 2 unmodified lines
275296 userId: "user-1",
276297 resource: defaultMcpResource,
277298 };
278− const storage = new MemoryStorage();
279299 const server = makeServer();
280300 await server.connect(new StaleCloseTransport());
281301
@@ -1766,3 +1786,110 @@ describe("McpAgentSessionDOBase residency cap eviction", () => { · 1484 unmodified lines
17661786 });
17671787 });
17681788 });
1789+
1790+// The request-id ledger (`__mcp_stream_reqs__:<streamId>`, written by the
1791+// patched McpAgent — see patches/agents@0.17.3.patch) is the only durable
1792+// record that a POST is still owed a response. A row exists from the moment the
1793+// request is accepted until its final response is written, so a row that
1794+// outlives the incarnation which accepted it is a request nothing will ever
1795+// answer: the isolate was reset mid-execute. Each row carries the epoch of the
1796+// incarnation that wrote it, and that is what separates "stranded" from
1797+// "legitimately still running" — a browser-approval pause holds a row open for
1798+// minutes inside ONE incarnation and must never be swept.
1799+describe("McpAgentSessionDOBase stranded-request ledger", () => {
1800+ const ledgerKey = (streamId: string) => `__mcp_stream_reqs__:${streamId}`;
1801+
1802+ it("stamps the accepting incarnation on every ledger row", async () => {
1803+ const session = await makeHarnessSession();
1804+
1805+ await session.setStreamRequestIds("stream-a", [1, "two"]);
1806+
1807+ expect(await session.ctx.storage.get(ledgerKey("stream-a"))).toEqual({
1808+ epoch: await session.currentSessionEpoch(),
1809+ requestIds: [1, "two"],
1810+ });
1811+ expect(
1812+ await session.getStreamRequestIds("stream-a"),
1813+ "readers still see a plain request-id list",
1814+ ).toEqual([1, "two"]);
1815+ });
1816+
1817+ it("does not treat a row from the running incarnation as stranded", async () => {
1818+ const session = await makeHarnessSession();
1819+
1820+ // What a browser-approval pause looks like: accepted, unanswered, and
1821+ // legitimately going to stay that way for minutes.
1822+ await session.setStreamRequestIds("stream-paused", [9]);
1823+
1824+ expect(await session.getStaleEpochStreamRequestIds()).toEqual([]);
1825+ });
1826+
1827+ it("reports a row left by a previous incarnation as stranded", async () => {
1828+ const storage = new MemoryStorage();
1829+ const beforeReset = await makeHarnessSession(storage);
1830+ await beforeReset.setStreamRequestIds("stream-lost", [42]);
1831+ await beforeReset.setStreamRequestIds("stream-also-lost", ["abc"]);
1832+
1833+ // The reset: same durable storage, a brand new Durable Object instance.
1834+ const afterReset = await makeHarnessSession(storage);
1835+ await afterReset.setStreamRequestIds("stream-live", [100]);
1836+
1837+ const stranded = await afterReset.getStaleEpochStreamRequestIds();
1838+
1839+ // Order follows storage's key order, which this fake does not model, so
1840+ // the assertion is on the set.
1841+ expect(
1842+ [...stranded].sort((a, b) => a.streamId.localeCompare(b.streamId)),
1843+ "only the rows the dead incarnation accepted",
1844+ ).toMatchObject([
1845+ { streamId: "stream-also-lost", requestIds: ["abc"] },
1846+ { streamId: "stream-lost", requestIds: [42] },
1847+ ]);
1848+ for (const row of stranded) expect(row.epoch).toBeLessThan(row.currentEpoch);
1849+ });
1850+
1851+ it("reports a pre-epoch ledger row as stranded", async () => {
1852+ const session = await makeHarnessSession();
1853+
1854+ // The shape rows had before they carried an epoch. One can only have been
1855+ // written by an earlier deployment, so it reads as epoch 0 and is swept.
1856+ await session.ctx.storage.put(ledgerKey("stream-legacy"), [7]);
1857+
1858+ expect(await session.getStaleEpochStreamRequestIds()).toEqual([
1859+ {
1860+ currentEpoch: await session.currentSessionEpoch(),
1861+ epoch: 0,
1862+ requestIds: [7],
1863+ streamId: "stream-legacy",
1864+ },
1865+ ]);
1866+ expect(
1867+ await session.getStreamRequestIds("stream-legacy"),
1868+ "and it is still readable as a request-id list",
1869+ ).toEqual([7]);
1870+ });
1871+
1872+ it("holds the idle lease for a request the running incarnation still owes", async () => {
1873+ const session = await makeHarnessSession();
1874+ await session.setStreamRequestIds("stream-live", [1]);
1875+
1876+ await session.alarm();
1877+
1878+ expect(session.initialized, "live work keeps the runtime resident").toBe(true);
1879+ expect(session.ctx.alarm, "and re-arms the lease").toBeGreaterThan(0);
1880+ });
1881+
1882+ it("does not let a stranded row extend the idle lease", async () => {
1883+ const storage = new MemoryStorage();
1884+ const beforeReset = await makeHarnessSession(storage);
1885+ await beforeReset.setStreamRequestIds("stream-lost", [1]);
1886+
1887+ const afterReset = await makeHarnessSession(storage);
1888+ await afterReset.alarm();
1889+
1890+ expect(
1891+ afterReset.initialized,
1892+ "a request nothing will ever answer is dead work, not running work",
1893+ ).toBe(false);
1894+ });
1895+});
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts+13−7
@@ -203,7 +203,6 @@ const AGENTS_DESTROY_PENDING_KEY = "cf_agents_destroy_pending"; · 202 unmodified lines
203203 const MCP_HTTP_METHOD_HEADER = "cf-mcp-method";
204204 const MCP_MESSAGE_HEADER = "cf-mcp-message";
205205 const MODEL_RESUME_FORWARD_TIMEOUT_MS = 10_000;
206−const MCP_STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:";
207206 const approvalResponseKey = (executionId: string) => `approval-response:${executionId}`;
208207 const BrowserApprovalDecisionStorage = Schema.Struct({
209208 response: ResumeResponsePayload,
@@ -752,13 +751,20 @@ export abstract class McpAgentSessionDOBase< · 542 unmodified lines
752751 // which survives disposeIdleRuntime, so a later reconnect GET re-inits the
753752 // DO and replays it. Counting them would make every delivered-but-unacked
754753 // POST response pin the runtime alive indefinitely.
755− const rows = await this.ctx.storage.list<readonly JsonRpcRequestId[]>({
756− prefix: MCP_STREAM_REQS_KEY_PREFIX,
757− limit: 1_000,
758− });
754+ //
755+ // Rows stamped with an epoch older than this incarnation's are dead work,
756+ // not running work: the isolate that was going to produce their response
757+ // was reset, so nothing will ever answer them and they must not hold the
758+ // runtime open. The transport's orphan sweep tells the client and removes
759+ // the row on the next GET; until then they simply do not count.
760+ const [openStreams, currentEpoch] = await Promise.all([
761+ this.getOpenStreamRequestIds(),
762+ this.currentSessionEpoch(),
763+ ]);
759764 let count = 0;
760− for (const requestIds of rows.values()) {
761− if (Array.isArray(requestIds)) count += requestIds.length;
765+ for (const stream of openStreams) {
766+ if (stream.epoch < currentEpoch) continue;
767+ count += stream.requestIds.length;
762768 }
763769 return count;
764770 }
packages/hosts/cloudflare/src/mcp/agents-post-stream-loss.test.ts+337−0
@@ -0,0 +1,337 @@
1+// Unit coverage for the POST bridge's lost-execution handling (see
2+// patches/agents@0.17.3.patch).
3+//
4+// The front worker answers a POST with HTTP 200 and an SSE body long before the
5+// Durable Object produces a result. When the DO is reset mid-execute (isolate
6+// memory/CPU limit, storage timeout, deploy) the bridge WebSocket closes, and
7+// the bridge used to simply close the writer: the client saw a cleanly
8+// terminated SSE stream carrying no JSON-RPC response, which is
9+// indistinguishable from "still working" until its own timeout fires. Same
10+// outcome if the DO never answers at all.
11+//
12+// Three properties are pinned here:
13+// 1. An abnormal WS close answers everything still outstanding with the
14+// -32010 session_reset error, then ends the stream.
15+// 2. The happy path is byte-identical — a delivered response leaves nothing
16+// outstanding, so nothing is synthesized.
17+// 3. The response deadline answers a stream the DO never replied on, and the
18+// constant that bounds it still clears the two source constants it has to
19+// track (it lives in a vendored dist and cannot import them).
20+import { readFileSync } from "node:fs";
21+
22+import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest";
23+import { MCP_POST_RESPONSE_DEADLINE_MS, McpAgent } from "agents/mcp";
24+import { Effect, Option, Schema } from "effect";
25+
26+import { PAUSED_APPROVAL_TIMEOUT_MS } from "@executor-js/host-mcp/tool-server";
27+
28+const SESSION_RESET_ERROR_CODE = -32010;
29+/** Margin the patch adds on top of the two bounded waits. */
30+const DEADLINE_MARGIN_MS = 60_000;
31+
32+type FakeWebSocket = EventTarget & {
33+ accepted: boolean;
34+ closeCode: number | undefined;
35+ closeReason: string | undefined;
36+ accept: () => void;
37+ close: (code?: number, reason?: string) => void;
38+ send: (message: string) => void;
39+};
40+
41+const JsonRpcErrorFrame = Schema.Struct({
42+ error: Schema.Struct({
43+ code: Schema.Number,
44+ data: Schema.Struct({ reason: Schema.String }),
45+ message: Schema.String,
46+ }),
47+ id: Schema.Union([Schema.String, Schema.Number]),
48+ jsonrpc: Schema.Literal("2.0"),
49+});
50+const decodeJsonRpcErrorFrame = Schema.decodeUnknownOption(
51+ Schema.fromJsonString(JsonRpcErrorFrame),
52+);
53+
54+/** Every `data:` payload in an SSE body that parses as a JSON-RPC error. */
55+const errorFrames = (body: string): ReadonlyArray<typeof JsonRpcErrorFrame.Type> =>
56+ body.split("\n").flatMap((line) => {
57+ if (!line.startsWith("data: ")) return [];
58+ const decoded = decodeJsonRpcErrorFrame(line.slice("data: ".length));
59+ return Option.isSome(decoded) ? [decoded.value] : [];
60+ });
61+
62+const flushMicrotasks = async (): Promise<void> => {
63+ await Promise.resolve();
64+ await Promise.resolve();
65+ await Promise.resolve();
66+};
67+
68+const drainResponse = async (response: Response): Promise<string> => {
69+ const decoder = new TextDecoder();
70+ let body = "";
71+
72+ await Effect.runPromise(
73+ Effect.ignore(
74+ Effect.tryPromise({
75+ try: () =>
76+ response.body?.pipeTo(
77+ new WritableStream<Uint8Array>({
78+ close: () => {
79+ body += decoder.decode();
80+ },
81+ write: (chunk) => {
82+ body += decoder.decode(chunk, { stream: true });
83+ },
84+ }),
85+ ) ?? Promise.resolve(),
86+ catch: () => undefined,
87+ }),
88+ ),
89+ );
90+
91+ return body;
92+};
93+
94+const makeExecutionContext = (): ExecutionContext => ({
95+ passThroughOnException: () => {},
96+ props: undefined,
97+ waitUntil: () => {},
98+});
99+
100+const makeWebSocket = (): FakeWebSocket => {
101+ const ws = new EventTarget() as FakeWebSocket;
102+ ws.accepted = false;
103+ ws.closeCode = undefined;
104+ ws.closeReason = undefined;
105+ ws.accept = () => {
106+ ws.accepted = true;
107+ };
108+ ws.close = (code?: number, reason?: string) => {
109+ ws.closeCode = code;
110+ ws.closeReason = reason;
111+ };
112+ ws.send = () => {};
113+ return ws;
114+};
115+
116+const makeNamespace = (ws: FakeWebSocket) => ({
117+ newUniqueId: () => ({ toString: () => "generated-session" }),
118+ idFromName: (name: string) => ({ equals: () => true, name, toString: () => name }),
119+ get: () => ({
120+ setName: async () => {},
121+ getInitializeRequest: async () => ({}),
122+ fetch: async () => ({ webSocket: ws }),
123+ }),
124+});
125+
126+const postToBridge = async (body: unknown) => {
127+ const ws = makeWebSocket();
128+ const handler = McpAgent.serve("/mcp", {
129+ binding: "MCP_SESSION",
130+ transport: "streamable-http",
131+ });
132+ const response = await handler.fetch(
133+ new Request("https://executor.sh/mcp", {
134+ body: JSON.stringify(body),
135+ headers: {
136+ accept: "application/json, text/event-stream",
137+ "content-type": "application/json",
138+ "mcp-session-id": "session-1",
139+ },
140+ method: "POST",
141+ }),
142+ { MCP_SESSION: makeNamespace(ws) },
143+ makeExecutionContext(),
144+ );
145+ return { response, ws };
146+};
147+
148+const toolCall = (id: number | string) => ({
149+ id,
150+ jsonrpc: "2.0" as const,
151+ method: "tools/call",
152+ params: { arguments: {}, name: "execute" },
153+});
154+
155+/**
156+ * Deliver one `cf_mcp_agent_event` envelope exactly as the patched
157+ * `writeSSEEvent` builds it, including the `respondedIds` field the bridge uses
158+ * to retire outstanding ids.
159+ */
160+const emitResponse = (ws: FakeWebSocket, message: { readonly id: number | string }, close = true) =>
161+ ws.dispatchEvent(
162+ new MessageEvent("message", {
163+ data: JSON.stringify({
164+ close: close ? true : undefined,
165+ event: `event: message\ndata: ${JSON.stringify(message)}\n\n`,
166+ respondedIds: [message.id],
167+ type: "cf_mcp_agent_event",
168+ }),
169+ }),
170+ );
171+
172+/**
173+ * A Durable Object reset closes the bridge socket with no close code at all —
174+ * `CloseEvent` is not reliably constructible across runtimes, so the shape the
175+ * handler actually reads (`code` / `reason`) is attached directly.
176+ */
177+const emitAbnormalClose = (ws: FakeWebSocket, code?: number, reason?: string) =>
178+ ws.dispatchEvent(Object.assign(new Event("close"), { code, reason }));
179+
180+describe("POST bridge: lost-execution visibility", () => {
181+ beforeEach(() => {
182+ vi.useFakeTimers();
183+ vi.setSystemTime(0);
184+ vi.spyOn(console, "warn").mockImplementation(() => {});
185+ });
186+
187+ afterEach(() => {
188+ vi.restoreAllMocks();
189+ vi.useRealTimers();
190+ });
191+
192+ it("answers every outstanding request when the bridge socket closes abnormally", async () => {
193+ const { response, ws } = await postToBridge([toolCall(1), toolCall("two")]);
194+ const drained = drainResponse(response);
195+
196+ emitAbnormalClose(ws);
197+ const body = await drained;
198+
199+ const frames = errorFrames(body);
200+ expect(
201+ frames.map((frame) => frame.id),
202+ "one error per unanswered request id, preserving the id's JSON type",
203+ ).toEqual([1, "two"]);
204+ for (const frame of frames) {
205+ expect(frame.error.code).toBe(SESSION_RESET_ERROR_CODE);
206+ expect(frame.error.data.reason).toBe("session_reset");
207+ expect(frame.error.message).toContain("Execution lost");
208+ }
209+ });
210+
211+ it("writes nothing extra when the response was delivered before the close", async () => {
212+ const { response, ws } = await postToBridge(toolCall(1));
213+ const drained = drainResponse(response);
214+
215+ const result = { id: 1, jsonrpc: "2.0" as const, result: { ok: true } };
216+ emitResponse(ws, result);
217+ await flushMicrotasks();
218+ emitAbnormalClose(ws);
219+ const body = await drained;
220+
221+ expect(body, "the delivered result is the only frame on the wire").toBe(
222+ `event: message\ndata: ${JSON.stringify(result)}\n\n`,
223+ );
224+ expect(errorFrames(body)).toEqual([]);
225+ expect(ws.closeCode, "the happy-path close is unchanged").toBe(1000);
226+ expect(ws.closeReason).toBe("SSE response delivered");
227+ });
228+
229+ it("answers the request itself when the DO never responds before the deadline", async () => {
230+ const { response, ws } = await postToBridge(toolCall(7));
231+ const drained = drainResponse(response);
232+
233+ await vi.advanceTimersByTimeAsync(MCP_POST_RESPONSE_DEADLINE_MS - 1);
234+ expect(errorFrames(await Promise.race([drained, Promise.resolve("")]))).toEqual([]);
235+
236+ await vi.advanceTimersByTimeAsync(2);
237+ const body = await drained;
238+
239+ const frames = errorFrames(body);
240+ expect(frames.map((frame) => frame.id)).toEqual([7]);
241+ expect(frames[0]?.error.code).toBe(SESSION_RESET_ERROR_CODE);
242+ expect(frames[0]?.error.data.reason).toBe("response_deadline");
243+ expect(vi.getTimerCount(), "the deadline and the keepalive are both disarmed").toBe(0);
244+
245+ // A close arriving after the bridge already answered must not double-write.
246+ emitAbnormalClose(ws);
247+ await flushMicrotasks();
248+ expect(errorFrames(body).length).toBe(1);
249+ });
250+
251+ it("arms no deadline for a body that owes no response", async () => {
252+ const { response } = await postToBridge({
253+ jsonrpc: "2.0",
254+ method: "notifications/initialized",
255+ });
256+
257+ expect(response.status).toBe(202);
258+ expect(vi.getTimerCount(), "a notifications-only POST leaves no timer behind").toBe(0);
259+ });
260+});
261+
262+describe("POST response deadline: drift against the waits it must cover", () => {
263+ /**
264+ * Evaluate the small `a * b + c` expressions these constants are declared
265+ * with. Anything outside digits, `_`, `*`, `+` and the named substitutions is
266+ * refused, so a declaration that grows a new shape fails the test loudly
267+ * instead of silently reading as `NaN`.
268+ */
269+ const evaluate = (expression: string, known: Readonly<Record<string, number>>): number | null => {
270+ let total = 0;
271+ for (const term of expression.split("+")) {
272+ let product = 1;
273+ for (const rawFactor of term.split("*")) {
274+ const factor = rawFactor.trim();
275+ const named = known[factor];
276+ if (named !== undefined) {
277+ product *= named;
278+ continue;
279+ }
280+ if (!/^\d[\d_]*$/.test(factor)) return null;
281+ product *= Number(factor.replaceAll("_", ""));
282+ }
283+ total += product;
284+ }
285+ return total;
286+ };
287+
288+ /**
289+ * Read a constant out of another package's source. Deliberately a file read
290+ * and not an import: `DEFAULT_TIMEOUT_MS` is private to the dynamic-worker
291+ * runtime, the bridge constant that has to cover it lives in a vendored dist
292+ * that can import neither, and this test exists precisely to notice when the
293+ * two drift apart.
294+ */
295+ const readConstant = (
296+ relativePath: string,
297+ name: string,
298+ known: Readonly<Record<string, number>> = {},
299+ ): number | null => {
300+ const source = readFileSync(new URL(relativePath, import.meta.url), "utf8");
301+ const match = new RegExp(`\\b${name}\\s*=\\s*([^;\\n]+);`).exec(source);
302+ return match?.[1] === undefined ? null : evaluate(match[1], known);
303+ };
304+
305+ const RUNTIME_EXECUTOR = "../../../../kernel/runtime-dynamic-worker/src/executor.ts";
306+ const TOOL_SERVER = "../../../mcp/src/tool-server.ts";
307+
308+ it("covers the sandbox execution ceiling plus the browser-approval wait plus margin", () => {
309+ const sandboxTimeoutMs = readConstant(RUNTIME_EXECUTOR, "DEFAULT_TIMEOUT_MS");
310+ expect(sandboxTimeoutMs, `DEFAULT_TIMEOUT_MS not readable from ${RUNTIME_EXECUTOR}`).not.toBe(
311+ null,
312+ );
313+
314+ // The scrape is kept honest by the one constant that IS a public export:
315+ // if the regex ever stops reading these declarations correctly, this
316+ // mismatch fires rather than the test passing on a wrong number.
317+ const scrapedPausedApprovalMs = readConstant(TOOL_SERVER, "PAUSED_APPROVAL_TIMEOUT_MS");
318+ expect(scrapedPausedApprovalMs, "scraped value must match the real export").toBe(
319+ PAUSED_APPROVAL_TIMEOUT_MS,
320+ );
321+
322+ const browserApprovalWaitMs = readConstant(TOOL_SERVER, "BROWSER_APPROVAL_WAIT_TIMEOUT_MS", {
323+ PAUSED_APPROVAL_TIMEOUT_MS,
324+ });
325+ expect(
326+ browserApprovalWaitMs,
327+ `BROWSER_APPROVAL_WAIT_TIMEOUT_MS not readable from ${TOOL_SERVER}`,
328+ ).not.toBe(null);
329+
330+ const required = (sandboxTimeoutMs ?? 0) + (browserApprovalWaitMs ?? 0) + DEADLINE_MARGIN_MS;
331+ expect(
332+ MCP_POST_RESPONSE_DEADLINE_MS,
333+ "the bridge would answer for a call that is still legitimately running — raise " +
334+ "MCP_POST_RESPONSE_DEADLINE_MS in patches/agents@0.17.3.patch",
335+ ).toBeGreaterThanOrEqual(required);
336+ });
337+});
packages/hosts/cloudflare/src/mcp/agents-sse-max-age.test.ts+8−3
@@ -1,5 +1,5 @@
11 import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest";
2−import { MAX_SSE_AGE_MS, McpAgent } from "agents/mcp";
2+import { MAX_SSE_AGE_MS, MCP_POST_RESPONSE_DEADLINE_MS, McpAgent } from "agents/mcp";
33 import { Effect, Option, Schema } from "effect";
44
55 import { SESSION_TIMEOUT_MS } from "./session-alarm-policy";
@@ -377,12 +377,17 @@ describe("agents SSE max-age rotation", () => { · 371 unmodified lines
377377 await expect(drained).resolves.toContain(": max-age rotation, reconnect\n\n");
378378 });
379379
380− it("does not rotate an in-flight POST response past max age", async () => {
380+ // Max-age rotation applies only to GET streams. A POST is bounded by its own,
381+ // much shorter response deadline (MCP_POST_RESPONSE_DEADLINE_MS — see
382+ // agents-post-stream-loss.test.ts), so max age is never reachable on one; this
383+ // pins that rotation stays out of the way for as long as a POST may legitimately
384+ // run.
385+ it("does not rotate an in-flight POST response", async () => {
381386 const { response, ws } = await openPostSse();
382387 const drained = drainResponse(response);
383388
384389 emitAgentEvent(ws, `event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{}}\n\n`);
385− await vi.advanceTimersByTimeAsync(MAX_SSE_AGE_MS + KEEPALIVE_INTERVAL_MS * 4);
390+ await vi.advanceTimersByTimeAsync(MCP_POST_RESPONSE_DEADLINE_MS - KEEPALIVE_INTERVAL_MS);
386391 await flushMicrotasks();
387392
388393 expect(ws.closeCode).toBeUndefined();
packages/hosts/cloudflare/src/mcp/durable-object-errors.test.ts+14−5
@@ -91,11 +91,20 @@ describe("classifyDurableObjectError", () => { · 90 unmodified lines
9191 ).toEqual({ kind: "cpu_limit", disposition: "transient" });
9292 });
9393
94− // The memory-limit reset is the CPU limit's sibling and is deliberately NOT
95− // classified: the runtime names the application as the cause (un-awaited
96− // writes, an oversized read), so a retry reproduces it. It has to keep being
97− // rethrown and reported rather than disappearing into a 503.
98− it("refuses to classify the sibling memory-limit reset as retryable", () => {
94+ it("reads a plain memory-limit reset as transient", () => {
95+ expect(
96+ classifyDurableObjectError(
97+ new Error("Durable Object's isolate exceeded its memory limit and was reset."),
98+ ),
99+ ).toEqual({ kind: "memory_limit", disposition: "transient" });
100+ });
101+
102+ // The storage-cache variant shares the "exceeded its memory limit" phrase but
103+ // is a USER error the runtime names the cause of (un-awaited writes, an
104+ // oversized read), so a retry reproduces it. It has to keep being rethrown and
105+ // reported rather than disappearing into a 503 — the qualifier is the only
106+ // thing separating it from the platform reset above.
107+ it("refuses to classify the storage-cache memory-limit variant as retryable", () => {
99108 expect(
100109 classifyDurableObjectError(
101110 new Error(
packages/hosts/cloudflare/src/mcp/durable-object-errors.ts+37−7
@@ -41,6 +41,8 @@ export type DurableObjectFailureKind = · 40 unmodified lines
4141 | "concurrency_reset"
4242 /** An invocation ran past the per-invocation CPU ceiling; the object was reset. */
4343 | "cpu_limit"
44+ /** The isolate ran past its memory ceiling; every object in it was reset. */
45+ | "memory_limit"
4446 /** A generic platform blip: `internal error; reference = <id>`. */
4547 | "internal_error"
4648 /** The runtime itself flagged the error as retryable. */
@@ -68,6 +70,14 @@ export type DurableObjectFailure = { · 21 unmodified lines
6870 */
6971 const MESSAGE_PATTERNS: ReadonlyArray<{
7072 readonly fragment: string;
73+ /**
74+ * A second fragment that, when also present, *disqualifies* the match. One
75+ * runtime phrase covers both a platform reset and an application defect, and
76+ * the qualifier the runtime adds for the defect is the only thing separating
77+ * them — so the entry names that qualifier rather than trying to spell out
78+ * every benign variant of the shared phrase.
79+ */
80+ readonly excludeFragment?: string;
7181 readonly failure: DurableObjectFailure;
7282 }> = [
7383 {
@@ -125,6 +135,28 @@ const MESSAGE_PATTERNS: ReadonlyArray<{ · 51 unmodified lines
125135 fragment: "exceeded its cpu time limit and was reset",
126136 failure: { kind: "cpu_limit", disposition: "transient" },
127137 },
138+ {
139+ // The CPU limit's sibling, matched on the short shared phrase rather than a
140+ // full sentence because the runtime words its two memory-limit messages
141+ // differently after it.
142+ //
143+ // `excludeFragment` carries what used to keep this bucket out of the list
144+ // entirely: the runtime reuses "exceeded its memory limit" for a USER error
145+ // that names its own cause — "… due to overflowing the storage cache. All
146+ // objects in the isolate were reset." — too many un-awaited writes, or one
147+ // oversized read. That variant reproduces on every retry, so calling it
148+ // transient would bury an application defect behind a 503. It still falls
149+ // through unclassified and keeps being rethrown and reported.
150+ //
151+ // What is left is the plain reset, transient for exactly the reasons the
152+ // CPU limit is: nothing about the request caused it, durable storage is
153+ // untouched, and the session id still routes. Leaving it unclassified is
154+ // what let a memory-limit reset mid-execute fall out of the worker as an
155+ // unhandled 500.
156+ fragment: "exceeded its memory limit",
157+ excludeFragment: "overflowing the storage cache",
158+ failure: { kind: "memory_limit", disposition: "transient" },
159+ },
128160 {
129161 // Only the bare blip. A reference id at the end of the message is NOT the
130162 // marker: the runtime also appends one to described faults such as the
@@ -136,12 +168,6 @@ const MESSAGE_PATTERNS: ReadonlyArray<{ · 5 unmodified lines
136168 fragment: "internal error; reference =",
137169 failure: { kind: "internal_error", disposition: "transient" },
138170 },
139− // Not listed, on purpose: the sibling memory-limit reset ("Durable Object's
140− // isolate exceeded its memory limit due to overflowing the storage cache …
141− // All objects in the isolate were reset."). The runtime tags that one as a
142− // user error, and it names its own cause — too many un-awaited writes, or one
143− // oversized read. Retrying reproduces it, so calling it transient would bury
144− // an application defect behind a 503 instead of surfacing it.
145171 ];
146172
147173 /**
@@ -175,7 +201,11 @@ const classifyOne = (error: unknown): DurableObjectFailure | null => { · 27 unmodified lines
175201 return { kind: "destroyed", disposition: "session_dead" };
176202 }
177203 for (const pattern of MESSAGE_PATTERNS) {
178− if (normalized.includes(pattern.fragment)) return pattern.failure;
204+ if (!normalized.includes(pattern.fragment)) continue;
205+ if (pattern.excludeFragment !== undefined && normalized.includes(pattern.excludeFragment)) {
206+ continue;
207+ }
208+ return pattern.failure;
179209 }
180210 }
181211 // Checked last: the message is the more specific signal, and the runtime sets
patches/agents@0.17.3.patch+429−46
@@ -4,11 +4,17 @@ index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2 · 3 unmodified lines
44 diff --git a/node_modules/agents/.bun-tag-61b2f1517ab5ced4 b/.bun-tag-61b2f1517ab5ced4
55 new file mode 100644
66 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
7+diff --git a/node_modules/agents/.bun-tag-61d29b56a6079f1d b/.bun-tag-61d29b56a6079f1d
8+new file mode 100644
9+index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
10+diff --git a/node_modules/agents/.bun-tag-a6a47855632a2623 b/.bun-tag-a6a47855632a2623
11+new file mode 100644
12+index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
713 diff --git a/node_modules/agents/.bun-tag-c0c639aa2299e502 b/.bun-tag-c0c639aa2299e502
814 new file mode 100644
915 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
1016 diff --git a/dist/agent-tool-types-CNyE1iz_.d.ts b/dist/agent-tool-types-CNyE1iz_.d.ts
11−index 571eececebd5a1eaf7f2fbf5278801c4d34728ba..317e526489fcd3fd477a50525da0df666253bf0b 100644
17+index 571eececebd5a1eaf7f2fbf5278801c4d34728ba..010811ae74ef2d27f12f7fb2506848f1939d91d6 100644
1218 --- a/dist/agent-tool-types-CNyE1iz_.d.ts
1319 +++ b/dist/agent-tool-types-CNyE1iz_.d.ts
1420 @@ -480,7 +480,10 @@ declare class DurableObjectEventStore implements EventStore {
@@ -23,31 +29,76 @@ index 571eececebd5a1eaf7f2fbf5278801c4d34728ba..317e526489fcd3fd477a50525da0df66 · 8 unmodified lines
2329 getStreamIdForEventId(eventId: EventId): Promise<StreamId | undefined>;
2430 replayEventsAfter(
2531 lastEventId: EventId,
32+@@ -538,6 +541,42 @@ declare abstract class McpAgent<
33+ * @internal
34+ */
35+ private static readonly STREAM_REQS_KEY_PREFIX;
36++ /**
37++ * Monotonic id for THIS Durable Object incarnation, bumped once per live
38++ * instance. Ledger rows are stamped with it so a row that outlived the
39++ * isolate which was going to answer it is recognisable as stale.
40++ *
41++ * @internal
42++ */
43++ currentSessionEpoch(): Promise<number>;
44++ /**
45++ * Ledger rows stamped by an earlier incarnation: requests that nothing will
46++ * ever answer. Read-only — the caller writes the client's error first.
47++ *
48++ * @internal
49++ */
50++ getStaleEpochStreamRequestIds(): Promise<
51++ ReadonlyArray<{
52++ readonly streamId: string;
53++ readonly requestIds: RequestId[];
54++ readonly epoch: number;
55++ readonly currentEpoch: number;
56++ }>
57++ >;
58++ /**
59++ * Every POST stream still awaiting a response, with the incarnation that
60++ * accepted it. Callers that mean "work still running" must drop rows whose
61++ * `epoch` is below {@link currentSessionEpoch}.
62++ *
63++ * @internal
64++ */
65++ getOpenStreamRequestIds(): Promise<
66++ ReadonlyArray<{
67++ readonly streamId: string;
68++ readonly requestIds: RequestId[];
69++ readonly epoch: number;
70++ }>
71++ >;
72+ /** Persist the `requestIds` for a POST stream. @internal */
73+ setStreamRequestIds(streamId: string, requestIds: RequestId[]): Promise<void>;
74+ /** Read the persisted `requestIds` for a POST stream. @internal */
2675 diff --git a/dist/mcp/index.d.ts b/dist/mcp/index.d.ts
27−index c8fad448e8797b89690a99d93490d1363851b225..77f9fe3f6f2375eadc9f7a2974f0d202fe75b3bd 100644
76+index c8fad448e8797b89690a99d93490d1363851b225..d80f66f1532c29dda0a0477dc1ec9ef5fd1fca47 100644
2877 --- a/dist/mcp/index.d.ts
2978 +++ b/dist/mcp/index.d.ts
30−@@ -29,6 +29,7 @@ import {
79+@@ -29,6 +29,8 @@ import {
3180 xt as MCPClientOAuthCallbackConfig,
3281 zt as ElicitResult
3382 } from "../agent-tool-types-CNyE1iz_.js";
3483 +declare const MAX_SSE_AGE_MS = 1800000;
84++declare const MCP_POST_RESPONSE_DEADLINE_MS = 660000;
3585 export {
3686 type ClearableEventStore,
3787 type CreateMcpHandlerOptions,
38−@@ -41,6 +42,7 @@ export {
88+@@ -41,6 +43,8 @@ export {
3989 type MCPConnectionResult,
4090 type MCPDiscoverResult,
4191 type MCPServerOptions,
4292 + MAX_SSE_AGE_MS,
93++ MCP_POST_RESPONSE_DEADLINE_MS,
4394 MCP_SERVER_ID_MAX_LENGTH,
4495 McpAgent,
4596 type McpAuthContext,
4697 diff --git a/dist/mcp/index.js b/dist/mcp/index.js
47−index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5babd1219e17 100644
98+index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..231220c26b995a48e40dba993773c28786cca392 100644
4899 --- a/dist/mcp/index.js
49100 +++ b/dist/mcp/index.js
50−@@ -28,13 +28,17 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
101+@@ -28,13 +28,60 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
51102 const KEEPALIVE_INTERVAL_MS = 25e3;
52103 /** SSE comment frame the parser drops before any event dispatch. */
53104 const KEEPALIVE_FRAME = ": keepalive\n\n";
@@ -55,6 +106,49 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 1 unmodified line
55106 +// well above Executor's 5 minute session idle timeout so active clients rarely
56107 +// rotate.
57108 +const MAX_SSE_AGE_MS = 30 * 60 * 1000;
109++/**
110++* JSON-RPC error code for "the isolate that was running this call is gone".
111++* In the implementation-defined server range (-32000..-32099) and deliberately
112++* NOT the -32001 the worker already uses for session/transport failures, so a
113++* client can tell "the call you are waiting on died mid-flight, re-run it" from
114++* "this session id is dead, reconnect".
115++*/
116++const SESSION_RESET_ERROR_CODE = -32010;
117++const SESSION_RESET_ERROR_MESSAGE = "Execution lost: the session was reset before it produced a result. Re-run the call.";
118++/**
119++* How long the POST bridge waits for the Durable Object to answer a JSON-RPC
120++* request before answering for it.
121++*
122++* This file is a vendored dist and cannot import the two constants that
123++* actually bound how long an answer may legitimately take, so this MUST be kept
124++* at or above their sum:
125++*
126++* DEFAULT_TIMEOUT_MS packages/kernel/runtime-dynamic-worker/src/executor.ts
127++* (5 min sandbox execution ceiling)
128++* BROWSER_APPROVAL_WAIT_TIMEOUT_MS packages/hosts/mcp/src/tool-server.ts
129++* (PAUSED_APPROVAL_TIMEOUT_MS + 1s = 4 min 1 s)
130++*
131++* plus ~60s of margin for DO restore, storage and edge latency. 11 minutes
132++* clears 10 min 1 s. Drift is caught by
133++* packages/hosts/cloudflare/src/mcp/agents-post-stream-loss.test.ts,
134++* which recomputes the sum from those two source files.
135++*/
136++const MCP_POST_RESPONSE_DEADLINE_MS = 11 * 60 * 1000;
137++/**
138++* The JSON-RPC error response sent for a request that will never get a real
139++* answer. `reason` distinguishes the two ways we find out: `session_reset`
140++* (the DO end of the bridge went away, or a ledger row outlived its
141++* incarnation) and `response_deadline` (nothing came back in time).
142++*/
143++const sessionResetErrorResponse = (id, reason) => ({
144++ error: {
145++ code: SESSION_RESET_ERROR_CODE,
146++ data: { reason },
147++ message: SESSION_RESET_ERROR_MESSAGE
148++ },
149++ id,
150++ jsonrpc: "2.0"
151++});
58152 /**
59153 * Start an SSE keepalive on `writer`. Returns a `clearInterval` handle
60154 * that the stream cleanup must invoke when the stream closes.
@@ -67,7 +161,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 6 unmodified lines
67161 }, KEEPALIVE_INTERVAL_MS);
68162 return handle;
69163 }
70−@@ -180,10 +184,15 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
164+@@ -180,10 +227,30 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
71165 });
72166 return new Response(body, { status: 404 });
73167 }
@@ -83,11 +177,26 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 9 unmodified lines
83177 + let __writeChain = Promise.resolve();
84178 + let __sseClosed = false;
85179 + let keepAlive;
180++ let __responseDeadline;
181++ /**
182++ * JSON-RPC ids this POST still owes the client a response for.
183++ * Entries are removed as the DO reports responses (the
184++ * `respondedIds` field writeSSEEvent puts on the envelope).
185++ *
186++ * A non-empty set when the bridge finishes abnormally is exactly
187++ * the "lost execution" case: the client already holds a 200 with
188++ * an open SSE body, so a silently ended stream leaves it waiting
189++ * forever. Keyed by type+value because JSON-RPC ids may be
190++ * strings or numbers and 1 must not match "1".
191++ */
192++ const __outstanding = new Map();
193++ const __idKey = (id) => `${typeof id}:${String(id)}`;
194++ let __finished = false;
86195 + const existingHeaders = {};
87196 request.headers.forEach((value, key) => {
88197 existingHeaders[key] = value;
89198 });
90−@@ -206,45 +215,99 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
199+@@ -206,47 +273,159 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
91200 jsonrpc: "2.0"
92201 });
93202 return new Response(body, { status: 500 });
@@ -101,8 +210,10 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 7 unmodified lines
101210 + const __closeSse = () => {
102211 + if (__sseClosed) return;
103212 + __sseClosed = true;
213++ __finished = true;
104214 + try {
105215 + clearInterval(keepAlive);
216++ clearTimeout(__responseDeadline);
106217 + } catch {}
107218 + try {
108219 + ws.close(1013, "SSE client not draining");
@@ -112,8 +223,10 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 3 unmodified lines
112223 + const __markSseClientClosed = () => {
113224 + if (__sseClosed) return;
114225 + __sseClosed = true;
226++ __finished = true;
115227 + try {
116228 + clearInterval(keepAlive);
229++ clearTimeout(__responseDeadline);
117230 + } catch {}
118231 + writer.abort(new Error("SSE client disconnected")).catch(() => {});
119232 + };
@@ -148,6 +261,44 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 28 unmodified lines
148261 - ws.addEventListener("message", (event) => {
149262 + });
150263 + }
264++ for (const msg of messages) if (isJSONRPCRequest(msg)) __outstanding.set(__idKey(msg.id), msg.id);
265++ /**
266++ * End the response stream when it will not end on its own, and
267++ * answer whatever is still outstanding first.
268++ *
269++ * The Durable Object can be reset out from under an in-flight
270++ * execute (isolate memory/CPU limit, storage timeout, deploy). The
271++ * front worker has already returned HTTP 200 with an SSE body, so
272++ * before this the WS close simply closed the writer and the client
273++ * saw a cleanly terminated stream carrying no JSON-RPC response —
274++ * indistinguishable from "still working" until its own timeout.
275++ *
276++ * The happy path is byte-identical: every id has been reported
277++ * responded by then, so the set is empty and nothing is written.
278++ */
279++ const __finishAbnormally = async (reason) => {
280++ if (__finished) return;
281++ __finished = true;
282++ clearTimeout(__responseDeadline);
283++ clearInterval(keepAlive);
284++ if (__outstanding.size > 0) {
285++ console.warn(JSON.stringify({
286++ event: "mcp_post_stream_lost",
287++ outstandingCount: __outstanding.size,
288++ reason,
289++ sessionId
290++ }));
291++ for (const id of __outstanding.values()) __forwardSse(encoder.encode(`event: message\ndata: ${JSON.stringify(sessionResetErrorResponse(id, reason))}\n\n`));
292++ __outstanding.clear();
293++ }
294++ const pending = __writeChain;
295++ __sseClosed = true;
296++ await pending.catch(() => {});
297++ await writer.close().catch(() => {});
298++ };
299++ __responseDeadline = setTimeout(() => {
300++ __finishAbnormally("response_deadline").catch(console.error);
301++ }, MCP_POST_RESPONSE_DEADLINE_MS);
151302 + ws.addEventListener("message", (event) => {
152303 async function onMessage(event) {
153304 try {
@@ -163,8 +314,11 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 9 unmodified lines
163314 + const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
164315 + const message = JSON.parse(data);
165316 + if (message.type !== "cf_mcp_agent_event") return;
317++ if (Array.isArray(message.respondedIds)) for (const respondedId of message.respondedIds) __outstanding.delete(__idKey(respondedId));
166318 + const writePromise = __forwardSse(encoder.encode(message.event));
167319 + if (message.close) {
320++ __finished = true;
321++ clearTimeout(__responseDeadline);
168322 + clearInterval(keepAlive);
169323 + await writePromise;
170324 + await writer.close();
@@ -195,9 +349,9 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 24 unmodified lines
195349 - await writer.close().catch(() => {});
196350 + ws.addEventListener("error", (error) => {
197351 + async function onError(_error) {
198−+ __sseClosed = true;
199−+ clearInterval(keepAlive);
200−+ await writer.close().catch(() => {});
352++ // The DO end of the bridge failed. Nothing still
353++ // outstanding will ever be answered.
354++ await __finishAbnormally("session_reset");
201355 }
202356 onError(error).catch(console.error);
203357 });
@@ -205,15 +359,31 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 1 unmodified line
205359 - async function onClose() {
206360 - clearInterval(keepAlive);
207361 - await writer.close().catch(() => {});
208−+ ws.addEventListener("close", () => {
209−+ async function onClose() {
210−+ __sseClosed = true;
211−+ clearInterval(keepAlive);
212−+ await writer.close().catch(() => {});
362++ ws.addEventListener("close", (event) => {
363++ async function onClose(closeEvent) {
364++ // 1000/"SSE response delivered" is this bridge's OWN
365++ // happy-path close coming back round. Every other code —
366++ // including no code at all, which is what a reset Durable
367++ // Object produces — means the far end went away, so
368++ // anything unanswered gets a session_reset error. The
369++ // outstanding set makes this safe either way: after a
370++ // normal delivery it is empty and nothing is written.
371++ if (closeEvent?.code === 1000 && closeEvent?.reason === "SSE response delivered") {
372++ __finished = true;
373++ clearTimeout(__responseDeadline);
374++ __sseClosed = true;
375++ clearInterval(keepAlive);
376++ await writer.close().catch(() => {});
377++ return;
378++ }
379++ await __finishAbnormally("session_reset");
213380 }
214− onClose().catch(console.error);
381+- onClose().catch(console.error);
382++ onClose(event).catch(console.error);
215383 });
216−@@ -279,10 +342,16 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
384+ return new Response(readable, {
385+ headers: {
386+@@ -279,10 +458,16 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
217387 id: null,
218388 jsonrpc: "2.0"
219389 }), { status: 400 });
@@ -234,7 +404,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 14 unmodified lines
234404 props: ctx.props,
235405 jurisdiction: options.jurisdiction
236406 });
237−@@ -306,27 +375,116 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
407+@@ -306,27 +491,116 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
238408 if (!ws) {
239409 await writer.close();
240410 return new Response("Failed to establish WS to DO", { status: 500 });
@@ -366,7 +536,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 125 unmodified lines
366536 return new Response(readable, {
367537 headers: {
368538 "Cache-Control": "no-cache",
369−@@ -389,10 +547,16 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => {
539+@@ -389,10 +663,16 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => {
370540 const url = new URL(request.url);
371541 if (request.method === "GET" && basePattern.test(url)) {
372542 const sessionId = url.searchParams.get("sessionId") || namespace.newUniqueId().toString();
@@ -387,7 +557,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 14 unmodified lines
387557 endpointUrl.pathname = encodeURI(`${basePath}/message`);
388558 endpointUrl.searchParams.set("sessionId", sessionId);
389559 const endpointMessage = `event: endpoint\ndata: ${endpointUrl.pathname + endpointUrl.search + endpointUrl.hash}\n\n`;
390−@@ -414,35 +578,94 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => {
560+@@ -414,35 +694,94 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => {
391561 console.error("Failed to establish WebSocket connection");
392562 await writer.close();
393563 return new Response("Failed to establish WebSocket connection", { status: 500 });
@@ -500,15 +670,16 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 106 unmodified lines
500670 console.error("Error closing SSE connection:", error);
501671 }
502672 }
503−@@ -586,6 +809,7 @@ var StreamableHTTPServerTransport = class {
673+@@ -586,6 +925,8 @@ var StreamableHTTPServerTransport = class {
504674 constructor(options) {
505675 this._started = false;
506676 this._streamResponseIds = /* @__PURE__ */ new Map();
507677 + this._replayInFlight = /* @__PURE__ */ new Map();
678++ this._sweptStaleStreams = /* @__PURE__ */ new Set();
508679 const { agent } = getCurrentAgent();
509680 if (!agent) throw new Error("McpAgent was not found in Transport constructor");
510681 this._agent = agent;
511−@@ -627,23 +851,74 @@ var StreamableHTTPServerTransport = class {
682+@@ -627,23 +968,146 @@ var StreamableHTTPServerTransport = class {
512683 const resumedStreamId = await this._eventStore.getStreamIdForEventId?.(lastEventId);
513684 if (resumedStreamId) {
514685 const resumeState = { streamId: resumedStreamId };
@@ -535,6 +706,14 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 20 unmodified lines
535706 + const ackStreamIds = [];
536707 + const replayedResponse = await this.replayEvents(lastEventId);
537708 + if (resumedStreamId !== STANDALONE_STREAM_ID && replayedResponse) ackStreamIds.push(resumedStreamId);
709++ // Nothing to replay on a POST stream means one of two things: the
710++ // work is still running in THIS incarnation, in which case the
711++ // stream must stay attached so the response routes here, or the
712++ // incarnation that owned it is gone and no response will ever
713++ // exist. Only the second is swept, and only for the stream this
714++ // cursor names. Before this, that case fell through with no close
715++ // frame and the recovery GET hung open on keepalives.
716++ if (resumedStreamId !== STANDALONE_STREAM_ID && !replayedResponse && (await this.failStaleEpochRequests(agent, connection, resumedStreamId)).length > 0) ackStreamIds.push(resumedStreamId);
538717 + // Last-Event-ID identifies one disconnected stream. Keep replay
539718 + // scoped to that stream: mixing another POST's response into this
540719 + // recovery stream can make a client stop reconnecting before the
@@ -565,12 +744,17 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 24 unmodified lines
565744 };
566745 connection.setState(standaloneState);
567746 + const replayedStreamIds = await this.replayUndeliveredResponsesOnFreshGet(agent, connection);
747++ // The standalone stream is also where orphans go. A POST stranded by a
748++ // reset may have no client left holding its cursor (its own recovery GET
749++ // never came), so every stale-epoch id is answered here instead. Both
750++ // sweeps delete the row, so an id is answered at most once.
751++ const staleStreamIds = await this.failStaleEpochRequests(agent, connection);
568752 + // A GET without Last-Event-ID has no identified stream to preserve, so
569753 + // retain the existing fallback that drains completed POST responses.
570754 + // Same delivery-confirmed clearing as the resume branch above. When
571−+ // nothing was replayed no close frame is sent and this stays the
572−+ // session's long-lived standalone listener.
573−+ if (replayedStreamIds.length > 0) this.sendReplayComplete(connection, replayedStreamIds);
755++ // nothing was replayed or swept no close frame is sent and this stays
756++ // the session's long-lived standalone listener.
757++ if (replayedStreamIds.length > 0 || staleStreamIds.length > 0) this.sendReplayComplete(connection, [...replayedStreamIds, ...staleStreamIds]);
574758 + }
575759 + /**
576760 + * Ask the Worker bridge to flush everything queued on `connection`,
@@ -586,10 +770,69 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 9 unmodified lines
586770 + ackStreamIds,
587771 + close: true
588772 + }));
773++ }
774++ /**
775++ * Answer every request left stranded by a PREVIOUS Durable Object
776++ * incarnation, and retire its ledger row.
777++ *
778++ * A `__mcp_stream_reqs__:<streamId>` row exists only while a POST's requests
779++ * are unanswered — writing the final response deletes it. Each row is stamped
780++ * with the epoch of the incarnation that accepted the request, so a row whose
781++ * epoch is older than the running one is proof that the isolate which was
782++ * going to produce that response no longer exists (memory/CPU limit, storage
783++ * timeout, deploy). No code path will ever answer it.
784++ *
785++ * Same-epoch rows are deliberately left alone: a browser-approval pause
786++ * legitimately holds a row open for minutes inside one incarnation, and that
787++ * work is still live.
788++ *
789++ * `onlyStreamId` scopes the sweep to the stream a Last-Event-ID cursor
790++ * identifies, so a recovery GET is never handed an unrelated request's error
791++ * (see the replay-scoping note in handleGetRequest).
792++ *
793++ * Returns the stream ids it swept, so the caller can send the close frame
794++ * that makes the client's recovery GET end instead of hanging on keepalives.
795++ */
796++ async failStaleEpochRequests(agent, connection, onlyStreamId) {
797++ if (typeof agent.getStaleEpochStreamRequestIds !== "function") return [];
798++ const stale = await agent.getStaleEpochStreamRequestIds();
799++ const sweptStreamIds = [];
800++ for (const row of stale) {
801++ if (onlyStreamId !== void 0 && row.streamId !== onlyStreamId) continue;
802++ // Claimed synchronously, before the first await below: a client may
803++ // hold two listeners (mcp-remote does), so two GETs can read the same
804++ // rows and interleave at the delete. Without this the client would
805++ // receive the same error response twice for one request id.
806++ if (this._sweptStaleStreams.has(row.streamId)) continue;
807++ this._sweptStaleStreams.add(row.streamId);
808++ console.warn(JSON.stringify({
809++ event: "mcp_stale_epoch_requests_failed",
810++ currentEpoch: row.currentEpoch,
811++ requestIds: row.requestIds,
812++ rowEpoch: row.epoch,
813++ streamId: row.streamId
814++ }));
815++ for (const requestId of row.requestIds) try {
816++ // No event id on purpose: this error belongs to a stream whose
817++ // event log is not this connection's cursor space, and it must
818++ // not be replayed to a later reconnect as that stream's history.
819++ this.writeSSEEvent(connection, sessionResetErrorResponse(requestId, "session_reset"));
820++ } catch (error) {
821++ this.onerror?.(error);
822++ }
823++ // Dropped now rather than on the delivery ack (unlike a replayed
824++ // response, which is idempotent and worth retrying): a surviving row
825++ // would be re-swept and re-errored on every later GET. A client that
826++ // missed this write gets the same answer from the POST bridge's own
827++ // close/deadline synthesis.
828++ await agent.deleteStreamRequestIds(row.streamId);
829++ sweptStreamIds.push(row.streamId);
830++ }
831++ return sweptStreamIds;
589832 }
590833 /**
591834 * Close any connection (other than `selfId`) currently bound to
592−@@ -651,6 +926,9 @@ var StreamableHTTPServerTransport = class {
835+@@ -651,6 +1115,9 @@ var StreamableHTTPServerTransport = class {
593836 * Closing rather than mutating sibling state mirrors how the SDK's
594837 * single `_streamMapping` entry gives last-writer-wins for free, and
595838 * keeps `send()` from routing to a stale bridge.
@@ -599,7 +842,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 3 unmodified lines
599842 */
600843 supersedePriorStreamConnections(agent, selfId, streamId) {
601844 for (const other of agent.getConnections()) {
602−@@ -664,12 +942,14 @@ var StreamableHTTPServerTransport = class {
845+@@ -664,12 +1131,14 @@ var StreamableHTTPServerTransport = class {
603846 * Only used when resumability is enabled
604847 */
605848 async replayEvents(lastEventId) {
@@ -615,7 +858,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 9 unmodified lines
615858 this.writeSSEEvent(connection, message, eventId);
616859 } catch (error) {
617860 this.onerror?.(error);
618−@@ -678,6 +958,45 @@ var StreamableHTTPServerTransport = class {
861+@@ -678,6 +1147,45 @@ var StreamableHTTPServerTransport = class {
619862 } catch (error) {
620863 this.onerror?.(error);
621864 }
@@ -661,12 +904,16 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 39 unmodified lines
661904 }
662905 /**
663906 * Writes an event to the SSE stream with proper formatting
664−@@ -689,10 +1008,65 @@ var StreamableHTTPServerTransport = class {
907+@@ -689,10 +1197,69 @@ var StreamableHTTPServerTransport = class {
665908 return connection.send(JSON.stringify({
666909 type: "cf_mcp_agent_event",
667910 event: eventData,
668911 + eventId,
669912 + streamId: eventId ? eventId.slice(0, eventId.lastIndexOf(":")) : void 0,
913++ // Lets the POST bridge retire the request ids it is holding without
914++ // re-parsing the SSE frame it is forwarding. An id still outstanding
915++ // when that bridge's WS dies is what it synthesizes an error for.
916++ respondedIds: isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message) ? [message.id] : void 0,
670917 close
671918 }));
672919 }
@@ -727,7 +974,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 54 unmodified lines
727974 * Handles POST requests containing JSON-RPC messages
728975 */
729976 async handlePostRequest(req, parsedBody) {
730−@@ -733,6 +1107,22 @@ var StreamableHTTPServerTransport = class {
977+@@ -733,6 +1300,22 @@ var StreamableHTTPServerTransport = class {
731978 };
732979 connection.setState(postState);
733980 if (this._eventStore) await agent.setStreamRequestIds(streamId, requestIds);
@@ -750,7 +997,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 16 unmodified lines
750997 for (const message of messages) {
751998 if (this.messageInterceptor) {
752999 if (await this.messageInterceptor(message, {
753−@@ -760,7 +1150,22 @@ var StreamableHTTPServerTransport = class {
1000+@@ -760,7 +1343,22 @@ var StreamableHTTPServerTransport = class {
7541001 * when the originating WS has dropped.
7551002 */
7561003 async sendOnStream(agent, streamId, relatedIds, liveConnection, message, requestId) {
@@ -774,7 +1021,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 17 unmodified lines
7741021 let shouldClose = false;
7751022 if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
7761023 let responseIds = this._streamResponseIds.get(streamId);
777−@@ -777,9 +1182,11 @@ var StreamableHTTPServerTransport = class {
1024+@@ -777,9 +1375,11 @@ var StreamableHTTPServerTransport = class {
7781025 } catch (error) {
7791026 this.onerror?.(error);
7801027 }
@@ -788,7 +1035,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 7 unmodified lines
7881035 }
7891036 }
7901037 async send(message, options) {
791−@@ -798,14 +1205,19 @@ var StreamableHTTPServerTransport = class {
1038+@@ -798,14 +1398,19 @@ var StreamableHTTPServerTransport = class {
7921039 *
7931040 * Sent on exactly one stream, per MCP: "the server MUST send each of
7941041 * its JSON-RPC messages on only one of the connected streams; it MUST
@@ -812,7 +1059,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 17 unmodified lines
8121059 if (standalone) this.writeSSEEvent(standalone, message, eventId);
8131060 }
8141061 /**
815−@@ -861,12 +1273,10 @@ var StreamableHTTPServerTransport = class {
1062+@@ -861,12 +1466,10 @@ var StreamableHTTPServerTransport = class {
8161063 *
8171064 * ## Lifecycle
8181065 *
@@ -829,7 +1076,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 10 unmodified lines
8291076 *
8301077 * Standalone GET stream events (`_GET_stream`) are *not* cleared
8311078 * automatically; they accumulate for the lifetime of the DO. Bounded
832−@@ -893,12 +1303,34 @@ var DurableObjectEventStore = class DurableObjectEventStore {
1079+@@ -893,12 +1496,34 @@ var DurableObjectEventStore = class DurableObjectEventStore {
8331080 }
8341081 async storeEvent(streamId, message) {
8351082 if (streamId.includes(":")) throw new Error(`DurableObjectEventStore: streamId must not contain ':' (got ${JSON.stringify(streamId)})`);
@@ -864,7 +1111,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 28 unmodified lines
8641111 return eventId;
8651112 }
8661113 async getStreamIdForEventId(eventId) {
867−@@ -915,9 +1347,59 @@ var DurableObjectEventStore = class DurableObjectEventStore {
1114+@@ -915,9 +1540,59 @@ var DurableObjectEventStore = class DurableObjectEventStore {
8681115 start: startKey,
8691116 limit: DurableObjectEventStore.REPLAY_LIMIT
8701117 });
@@ -925,7 +1172,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 54 unmodified lines
9251172 /**
9261173 * Drop the event log for a single stream. Called by the transport
9271174 * immediately after a POST's final response has been written to the
928−@@ -973,6 +1455,13 @@ DurableObjectEventStore.EVENT_KEY_PREFIX = "__mcp_event__:";
1175+@@ -973,6 +1648,13 @@ DurableObjectEventStore.EVENT_KEY_PREFIX = "__mcp_event__:";
9291176 DurableObjectEventStore.SEQ_PAD = 16;
9301177 DurableObjectEventStore.DELETE_CHUNK = 128;
9311178 DurableObjectEventStore.REPLAY_LIMIT = 1e3;
@@ -939,7 +1186,106 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 7 unmodified lines
9391186 //#endregion
9401187 //#region src/mcp/client-transports.ts
9411188 /**
942−@@ -1381,6 +1870,48 @@ var McpAgent = class McpAgent extends Agent {
1189+@@ -1355,6 +2037,26 @@ function experimental_createMcpHandler(server, options = {}) {
1190+ }
1191+ //#endregion
1192+ //#region src/mcp/index.ts
1193++/**
1194++* Normalize a `__mcp_stream_reqs__:` ledger row.
1195++*
1196++* Rows were a bare `requestIds` array before they carried an epoch. A row still
1197++* in that shape can only have been written by an earlier deployment — that is,
1198++* by an earlier incarnation — so epoch 0, below every real epoch (they start at
1199++* 1), is exactly the right reading: the orphan sweep retires it. No data
1200++* migration is needed because a row's whole lifetime is one unanswered POST.
1201++*/
1202++function readStreamRequestIdsRow(value) {
1203++ if (Array.isArray(value)) return {
1204++ epoch: 0,
1205++ requestIds: value
1206++ };
1207++ if (value !== null && typeof value === "object" && Array.isArray(value.requestIds)) return {
1208++ epoch: typeof value.epoch === "number" ? value.epoch : 0,
1209++ requestIds: value.requestIds
1210++ };
1211++ return void 0;
1212++}
1213+ var McpAgent = class McpAgent extends Agent {
1214+ constructor(..._args) {
1215+ super(..._args);
1216+@@ -1369,18 +2071,121 @@ var McpAgent = class McpAgent extends Agent {
1217+ async getInitializeRequest() {
1218+ return this.ctx.storage.get("initializeRequest");
1219+ }
1220+- /** Persist the `requestIds` for a POST stream. @internal */
1221++ /**
1222++ * Monotonic id for THIS Durable Object incarnation, bumped exactly once per
1223++ * live instance the first time anything asks for it (the promise is
1224++ * memoized on the instance, so concurrent first callers share one bump).
1225++ *
1226++ * A reset — isolate memory/CPU limit, storage timeout, deploy — throws the
1227++ * instance away and constructs a new one, so the next read bumps again and
1228++ * every ledger row written by the dead incarnation becomes recognisably
1229++ * stale.
1230++ *
1231++ * Deliberately NOT hung off onStart()/init(): both re-run inside a single
1232++ * incarnation (a restore-after-idle-dispose calls onStart again), which
1233++ * would strand still-running work as "stale". onStart does force the first
1234++ * read, so the stored value is current before any request handler runs and
1235++ * readers outside this class can compare against it.
1236++ * @internal
1237++ */
1238++ currentSessionEpoch() {
1239++ this._sessionEpoch ??= (async () => {
1240++ const stored = await this.ctx.storage.get(McpAgent.SESSION_EPOCH_KEY);
1241++ const next = (typeof stored === "number" && Number.isFinite(stored) ? stored : 0) + 1;
1242++ await this.ctx.storage.put(McpAgent.SESSION_EPOCH_KEY, next);
1243++ return next;
1244++ })();
1245++ return this._sessionEpoch;
1246++ }
1247++ /** Persist the `requestIds` for a POST stream, stamped with the incarnation
1248++ * that accepted them so a reset leaves a recognisable orphan. @internal */
1249+ async setStreamRequestIds(streamId, requestIds) {
1250+- await this.ctx.storage.put(`${McpAgent.STREAM_REQS_KEY_PREFIX}${streamId}`, requestIds);
1251++ const epoch = await this.currentSessionEpoch();
1252++ await this.ctx.storage.put(`${McpAgent.STREAM_REQS_KEY_PREFIX}${streamId}`, {
1253++ epoch,
1254++ requestIds
1255++ });
1256+ }
1257+ /** Read the persisted `requestIds` for a POST stream. @internal */
1258+ async getStreamRequestIds(streamId) {
1259+- return this.ctx.storage.get(`${McpAgent.STREAM_REQS_KEY_PREFIX}${streamId}`);
1260++ return readStreamRequestIdsRow(await this.ctx.storage.get(`${McpAgent.STREAM_REQS_KEY_PREFIX}${streamId}`))?.requestIds;
1261++ }
1262++ /**
1263++ * Ledger rows stamped by an earlier incarnation: requests dispatched into an
1264++ * isolate that no longer exists, so nothing will ever produce a response for
1265++ * them. Read-only — the caller must write the client's error before it
1266++ * deletes anything. @internal
1267++ */
1268++ async getStaleEpochStreamRequestIds() {
1269++ const currentEpoch = await this.currentSessionEpoch();
1270++ const rows = await this.ctx.storage.list({
1271++ prefix: McpAgent.STREAM_REQS_KEY_PREFIX,
1272++ limit: 1e3
1273++ });
1274++ const stale = [];
1275++ for (const [key, value] of rows) {
1276++ const row = readStreamRequestIdsRow(value);
1277++ if (!row || row.requestIds.length === 0) continue;
1278++ if (row.epoch >= currentEpoch) continue;
1279++ stale.push({
1280++ currentEpoch,
1281++ epoch: row.epoch,
1282++ requestIds: row.requestIds,
1283++ streamId: key.slice(McpAgent.STREAM_REQS_KEY_PREFIX.length)
1284++ });
1285++ }
1286++ return stale;
1287+ }
1288+ /** Drop the persisted `requestIds` for a POST stream. @internal */
9431289 async deleteStreamRequestIds(streamId) {
9441290 await this.ctx.storage.delete(`${McpAgent.STREAM_REQS_KEY_PREFIX}${streamId}`);
9451291 }
@@ -962,10 +1308,14 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 16 unmodified lines
9621308 + prefix: McpAgent.STREAM_REQS_KEY_PREFIX,
9631309 + limit: 1e3
9641310 + });
965−+ return [...rows].flatMap(([key, requestIds]) => Array.isArray(requestIds) && requestIds.length > 0 ? [{
966−+ streamId: key.slice(McpAgent.STREAM_REQS_KEY_PREFIX.length),
967−+ requestIds
968−+ }] : []);
1311++ return [...rows].flatMap(([key, value]) => {
1312++ const row = readStreamRequestIdsRow(value);
1313++ return row && row.requestIds.length > 0 ? [{
1314++ epoch: row.epoch,
1315++ streamId: key.slice(McpAgent.STREAM_REQS_KEY_PREFIX.length),
1316++ requestIds: row.requestIds
1317++ }] : [];
1318++ });
9691319 + }
9701320 + async acknowledgeDeliveredStream(streamId) {
9711321 + await this.deleteStreamRequestIds(streamId);
@@ -988,7 +1338,39 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 16 unmodified lines
9881338 /**
9891339 * Reverse lookup: find which POST stream a given `requestId` belongs
9901340 * to, and return the stream's full `requestIds` list in the same
991−@@ -1516,23 +2047,36 @@ var McpAgent = class McpAgent extends Agent {
1341+@@ -1407,10 +2212,14 @@ var McpAgent = class McpAgent extends Agent {
1342+ limit: STREAM_REQS_SCAN_LIMIT
1343+ });
1344+ if (rows.size === STREAM_REQS_SCAN_LIMIT) console.warn(`McpAgent: getStreamForRequestId hit the ${STREAM_REQS_SCAN_LIMIT}-key scan cap; stale __mcp_stream_reqs__ entries may be accumulating from abandoned POSTs`);
1345+- for (const [key, requestIds] of rows) if (requestIds?.includes(requestId)) return {
1346+- streamId: key.slice(McpAgent.STREAM_REQS_KEY_PREFIX.length),
1347+- requestIds
1348+- };
1349++ for (const [key, value] of rows) {
1350++ const row = readStreamRequestIdsRow(value);
1351++ if (!row?.requestIds.includes(requestId)) continue;
1352++ return {
1353++ streamId: key.slice(McpAgent.STREAM_REQS_KEY_PREFIX.length),
1354++ requestIds: row.requestIds
1355++ };
1356++ }
1357+ }
1358+ /** Read the transport type for this agent.
1359+ * This relies on the naming scheme being `sse:${sessionId}`,
1360+@@ -1498,6 +2307,12 @@ var McpAgent = class McpAgent extends Agent {
1361+ }
1362+ /** Sets up the MCP transport and server every time the Agent is started.*/
1363+ async onStart(props) {
1364++ // Force the incarnation epoch before anything else can read or write the
1365++ // request-id ledger. partyserver runs onStart inside
1366++ // blockConcurrencyWhile at every entry point, so from here on the stored
1367++ // epoch IS this incarnation's, and readers outside this class (the DO's
1368++ // idle-lease accounting) can compare row epochs against storage directly.
1369++ await this.currentSessionEpoch();
1370+ if (props) await this.updateProps(props);
1371+ else this.props = await this.ctx.storage.get("props");
1372+ await this.init();
1373+@@ -1516,23 +2331,36 @@ var McpAgent = class McpAgent extends Agent {
9921374 return;
9931375 }
9941376 break;
@@ -1040,14 +1422,15 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 45 unmodified lines
10401422 }
10411423 }
10421424 }
1043−@@ -1697,7 +2241,8 @@ var McpAgent = class McpAgent extends Agent {
1425+@@ -1697,7 +2525,9 @@ var McpAgent = class McpAgent extends Agent {
10441426 }
10451427 };
10461428 McpAgent.STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:";
10471429 +McpAgent.UNDELIVERED_STREAM_KEY_PREFIX = "__mcp_undelivered_stream__:";
1430++McpAgent.SESSION_EPOCH_KEY = "__mcp_session_epoch__";
10481431 //#endregion
10491432 -export { DurableObjectEventStore, ElicitRequestSchema, MCP_SERVER_ID_MAX_LENGTH, McpAgent, RPCClientTransport, RPCServerTransport, RPC_DO_PREFIX, SSEEdgeClientTransport, StreamableHTTPEdgeClientTransport, WorkerTransport, createMcpHandler, experimental_createMcpHandler, getMcpAuthContext, normalizeServerId };
1050−+export { DurableObjectEventStore, ElicitRequestSchema, MAX_SSE_AGE_MS, MCP_SERVER_ID_MAX_LENGTH, McpAgent, RPCClientTransport, RPCServerTransport, RPC_DO_PREFIX, SSEEdgeClientTransport, StreamableHTTPEdgeClientTransport, WorkerTransport, createMcpHandler, experimental_createMcpHandler, getMcpAuthContext, normalizeServerId };
1433++export { DurableObjectEventStore, ElicitRequestSchema, MAX_SSE_AGE_MS, MCP_POST_RESPONSE_DEADLINE_MS, MCP_SERVER_ID_MAX_LENGTH, McpAgent, RPCClientTransport, RPCServerTransport, RPC_DO_PREFIX, SSEEdgeClientTransport, StreamableHTTPEdgeClientTransport, WorkerTransport, createMcpHandler, experimental_createMcpHandler, getMcpAuthContext, normalizeServerId };
10511434
10521435 //# sourceMappingURL=index.js.map
10531436 \ No newline at end of file

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t