| @@ -4,11 +4,17 @@ index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2 · 3 unmodified lines |
| 4 | diff --git a/node_modules/agents/.bun-tag-61b2f1517ab5ced4 b/.bun-tag-61b2f1517ab5ced4 | 4 | diff --git a/node_modules/agents/.bun-tag-61b2f1517ab5ced4 b/.bun-tag-61b2f1517ab5ced4 |
| 5 | new file mode 100644 | 5 | new file mode 100644 |
| 6 | index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 | 6 | 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 |
| 7 | diff --git a/node_modules/agents/.bun-tag-c0c639aa2299e502 b/.bun-tag-c0c639aa2299e502 | 13 | diff --git a/node_modules/agents/.bun-tag-c0c639aa2299e502 b/.bun-tag-c0c639aa2299e502 |
| 8 | new file mode 100644 | 14 | new file mode 100644 |
| 9 | index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 | 15 | index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 |
| 10 | diff --git a/dist/agent-tool-types-CNyE1iz_.d.ts b/dist/agent-tool-types-CNyE1iz_.d.ts | 16 | 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 |
| 12 | --- a/dist/agent-tool-types-CNyE1iz_.d.ts | 18 | --- a/dist/agent-tool-types-CNyE1iz_.d.ts |
| 13 | +++ b/dist/agent-tool-types-CNyE1iz_.d.ts | 19 | +++ b/dist/agent-tool-types-CNyE1iz_.d.ts |
| 14 | @@ -480,7 +480,10 @@ declare class DurableObjectEventStore implements EventStore { | 20 | @@ -480,7 +480,10 @@ declare class DurableObjectEventStore implements EventStore { |
| @@ -23,31 +29,76 @@ index 571eececebd5a1eaf7f2fbf5278801c4d34728ba..317e526489fcd3fd477a50525da0df66 · 8 unmodified lines |
| 23 | getStreamIdForEventId(eventId: EventId): Promise<StreamId | undefined>; | 29 | getStreamIdForEventId(eventId: EventId): Promise<StreamId | undefined>; |
| 24 | replayEventsAfter( | 30 | replayEventsAfter( |
| 25 | lastEventId: EventId, | 31 | 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 */ |
| 26 | diff --git a/dist/mcp/index.d.ts b/dist/mcp/index.d.ts | 75 | diff --git a/dist/mcp/index.d.ts b/dist/mcp/index.d.ts |
| 27 | index c8fad448e8797b89690a99d93490d1363851b225..77f9fe3f6f2375eadc9f7a2974f0d202fe75b3bd 100644 | 76 | index c8fad448e8797b89690a99d93490d1363851b225..d80f66f1532c29dda0a0477dc1ec9ef5fd1fca47 100644 |
| 28 | --- a/dist/mcp/index.d.ts | 77 | --- a/dist/mcp/index.d.ts |
| 29 | +++ b/dist/mcp/index.d.ts | 78 | +++ b/dist/mcp/index.d.ts |
| 30 | @@ -29,6 +29,7 @@ import { | 79 | @@ -29,6 +29,8 @@ import { |
| 31 | xt as MCPClientOAuthCallbackConfig, | 80 | xt as MCPClientOAuthCallbackConfig, |
| 32 | zt as ElicitResult | 81 | zt as ElicitResult |
| 33 | } from "../agent-tool-types-CNyE1iz_.js"; | 82 | } from "../agent-tool-types-CNyE1iz_.js"; |
| 34 | +declare const MAX_SSE_AGE_MS = 1800000; | 83 | +declare const MAX_SSE_AGE_MS = 1800000; |
| | 84 | +declare const MCP_POST_RESPONSE_DEADLINE_MS = 660000; |
| 35 | export { | 85 | export { |
| 36 | type ClearableEventStore, | 86 | type ClearableEventStore, |
| 37 | type CreateMcpHandlerOptions, | 87 | type CreateMcpHandlerOptions, |
| 38 | @@ -41,6 +42,7 @@ export { | 88 | @@ -41,6 +43,8 @@ export { |
| 39 | type MCPConnectionResult, | 89 | type MCPConnectionResult, |
| 40 | type MCPDiscoverResult, | 90 | type MCPDiscoverResult, |
| 41 | type MCPServerOptions, | 91 | type MCPServerOptions, |
| 42 | + MAX_SSE_AGE_MS, | 92 | + MAX_SSE_AGE_MS, |
| | 93 | + MCP_POST_RESPONSE_DEADLINE_MS, |
| 43 | MCP_SERVER_ID_MAX_LENGTH, | 94 | MCP_SERVER_ID_MAX_LENGTH, |
| 44 | McpAgent, | 95 | McpAgent, |
| 45 | type McpAuthContext, | 96 | type McpAuthContext, |
| 46 | diff --git a/dist/mcp/index.js b/dist/mcp/index.js | 97 | diff --git a/dist/mcp/index.js b/dist/mcp/index.js |
| 47 | index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5babd1219e17 100644 | 98 | index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..231220c26b995a48e40dba993773c28786cca392 100644 |
| 48 | --- a/dist/mcp/index.js | 99 | --- a/dist/mcp/index.js |
| 49 | +++ b/dist/mcp/index.js | 100 | +++ b/dist/mcp/index.js |
| 50 | @@ -28,13 +28,17 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/ | 101 | @@ -28,13 +28,60 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/ |
| 51 | const KEEPALIVE_INTERVAL_MS = 25e3; | 102 | const KEEPALIVE_INTERVAL_MS = 25e3; |
| 52 | /** SSE comment frame the parser drops before any event dispatch. */ | 103 | /** SSE comment frame the parser drops before any event dispatch. */ |
| 53 | const KEEPALIVE_FRAME = ": keepalive\n\n"; | 104 | const KEEPALIVE_FRAME = ": keepalive\n\n"; |
| @@ -55,6 +106,49 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 1 unmodified line |
| 55 | +// well above Executor's 5 minute session idle timeout so active clients rarely | 106 | +// well above Executor's 5 minute session idle timeout so active clients rarely |
| 56 | +// rotate. | 107 | +// rotate. |
| 57 | +const MAX_SSE_AGE_MS = 30 * 60 * 1000; | 108 | +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 | +}); |
| 58 | /** | 152 | /** |
| 59 | * Start an SSE keepalive on `writer`. Returns a `clearInterval` handle | 153 | * Start an SSE keepalive on `writer`. Returns a `clearInterval` handle |
| 60 | * that the stream cleanup must invoke when the stream closes. | 154 | * that the stream cleanup must invoke when the stream closes. |
| @@ -67,7 +161,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 6 unmodified lines |
| 67 | }, KEEPALIVE_INTERVAL_MS); | 161 | }, KEEPALIVE_INTERVAL_MS); |
| 68 | return handle; | 162 | return handle; |
| 69 | } | 163 | } |
| 70 | @@ -180,10 +184,15 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { | 164 | @@ -180,10 +227,30 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { |
| 71 | }); | 165 | }); |
| 72 | return new Response(body, { status: 404 }); | 166 | return new Response(body, { status: 404 }); |
| 73 | } | 167 | } |
| @@ -83,11 +177,26 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 9 unmodified lines |
| 83 | + let __writeChain = Promise.resolve(); | 177 | + let __writeChain = Promise.resolve(); |
| 84 | + let __sseClosed = false; | 178 | + let __sseClosed = false; |
| 85 | + let keepAlive; | 179 | + 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; |
| 86 | + const existingHeaders = {}; | 195 | + const existingHeaders = {}; |
| 87 | request.headers.forEach((value, key) => { | 196 | request.headers.forEach((value, key) => { |
| 88 | existingHeaders[key] = value; | 197 | existingHeaders[key] = value; |
| 89 | }); | 198 | }); |
| 90 | @@ -206,45 +215,99 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { | 199 | @@ -206,47 +273,159 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { |
| 91 | jsonrpc: "2.0" | 200 | jsonrpc: "2.0" |
| 92 | }); | 201 | }); |
| 93 | return new Response(body, { status: 500 }); | 202 | return new Response(body, { status: 500 }); |
| @@ -101,8 +210,10 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 7 unmodified lines |
| 101 | + const __closeSse = () => { | 210 | + const __closeSse = () => { |
| 102 | + if (__sseClosed) return; | 211 | + if (__sseClosed) return; |
| 103 | + __sseClosed = true; | 212 | + __sseClosed = true; |
| | 213 | + __finished = true; |
| 104 | + try { | 214 | + try { |
| 105 | + clearInterval(keepAlive); | 215 | + clearInterval(keepAlive); |
| | 216 | + clearTimeout(__responseDeadline); |
| 106 | + } catch {} | 217 | + } catch {} |
| 107 | + try { | 218 | + try { |
| 108 | + ws.close(1013, "SSE client not draining"); | 219 | + ws.close(1013, "SSE client not draining"); |
| @@ -112,8 +223,10 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 3 unmodified lines |
| 112 | + const __markSseClientClosed = () => { | 223 | + const __markSseClientClosed = () => { |
| 113 | + if (__sseClosed) return; | 224 | + if (__sseClosed) return; |
| 114 | + __sseClosed = true; | 225 | + __sseClosed = true; |
| | 226 | + __finished = true; |
| 115 | + try { | 227 | + try { |
| 116 | + clearInterval(keepAlive); | 228 | + clearInterval(keepAlive); |
| | 229 | + clearTimeout(__responseDeadline); |
| 117 | + } catch {} | 230 | + } catch {} |
| 118 | + writer.abort(new Error("SSE client disconnected")).catch(() => {}); | 231 | + writer.abort(new Error("SSE client disconnected")).catch(() => {}); |
| 119 | + }; | 232 | + }; |
| @@ -148,6 +261,44 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 28 unmodified lines |
| 148 | - ws.addEventListener("message", (event) => { | 261 | - ws.addEventListener("message", (event) => { |
| 149 | + }); | 262 | + }); |
| 150 | + } | 263 | + } |
| | 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); |
| 151 | + ws.addEventListener("message", (event) => { | 302 | + ws.addEventListener("message", (event) => { |
| 152 | async function onMessage(event) { | 303 | async function onMessage(event) { |
| 153 | try { | 304 | try { |
| @@ -163,8 +314,11 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 9 unmodified lines |
| 163 | + const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data); | 314 | + const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data); |
| 164 | + const message = JSON.parse(data); | 315 | + const message = JSON.parse(data); |
| 165 | + if (message.type !== "cf_mcp_agent_event") return; | 316 | + if (message.type !== "cf_mcp_agent_event") return; |
| | 317 | + if (Array.isArray(message.respondedIds)) for (const respondedId of message.respondedIds) __outstanding.delete(__idKey(respondedId)); |
| 166 | + const writePromise = __forwardSse(encoder.encode(message.event)); | 318 | + const writePromise = __forwardSse(encoder.encode(message.event)); |
| 167 | + if (message.close) { | 319 | + if (message.close) { |
| | 320 | + __finished = true; |
| | 321 | + clearTimeout(__responseDeadline); |
| 168 | + clearInterval(keepAlive); | 322 | + clearInterval(keepAlive); |
| 169 | + await writePromise; | 323 | + await writePromise; |
| 170 | + await writer.close(); | 324 | + await writer.close(); |
| @@ -195,9 +349,9 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 24 unmodified lines |
| 195 | - await writer.close().catch(() => {}); | 349 | - await writer.close().catch(() => {}); |
| 196 | + ws.addEventListener("error", (error) => { | 350 | + ws.addEventListener("error", (error) => { |
| 197 | + async function onError(_error) { | 351 | + async function onError(_error) { |
| 198 | + __sseClosed = true; | 352 | + // The DO end of the bridge failed. Nothing still |
| 199 | + clearInterval(keepAlive); | 353 | + // outstanding will ever be answered. |
| 200 | + await writer.close().catch(() => {}); | 354 | + await __finishAbnormally("session_reset"); |
| 201 | } | 355 | } |
| 202 | onError(error).catch(console.error); | 356 | onError(error).catch(console.error); |
| 203 | }); | 357 | }); |
| @@ -205,15 +359,31 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 1 unmodified line |
| 205 | - async function onClose() { | 359 | - async function onClose() { |
| 206 | - clearInterval(keepAlive); | 360 | - clearInterval(keepAlive); |
| 207 | - await writer.close().catch(() => {}); | 361 | - await writer.close().catch(() => {}); |
| 208 | + ws.addEventListener("close", () => { | 362 | + ws.addEventListener("close", (event) => { |
| 209 | + async function onClose() { | 363 | + async function onClose(closeEvent) { |
| 210 | + __sseClosed = true; | 364 | + // 1000/"SSE response delivered" is this bridge's OWN |
| 211 | + clearInterval(keepAlive); | 365 | + // happy-path close coming back round. Every other code — |
| 212 | + await writer.close().catch(() => {}); | 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"); |
| 213 | } | 380 | } |
| 214 | onClose().catch(console.error); | 381 | - onClose().catch(console.error); |
| | 382 | + onClose(event).catch(console.error); |
| 215 | }); | 383 | }); |
| 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 = {}) => { |
| 217 | id: null, | 387 | id: null, |
| 218 | jsonrpc: "2.0" | 388 | jsonrpc: "2.0" |
| 219 | }), { status: 400 }); | 389 | }), { status: 400 }); |
| @@ -234,7 +404,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 14 unmodified lines |
| 234 | props: ctx.props, | 404 | props: ctx.props, |
| 235 | jurisdiction: options.jurisdiction | 405 | jurisdiction: options.jurisdiction |
| 236 | }); | 406 | }); |
| 237 | @@ -306,27 +375,116 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { | 407 | @@ -306,27 +491,116 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { |
| 238 | if (!ws) { | 408 | if (!ws) { |
| 239 | await writer.close(); | 409 | await writer.close(); |
| 240 | return new Response("Failed to establish WS to DO", { status: 500 }); | 410 | return new Response("Failed to establish WS to DO", { status: 500 }); |
| @@ -366,7 +536,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 125 unmodified lines |
| 366 | return new Response(readable, { | 536 | return new Response(readable, { |
| 367 | headers: { | 537 | headers: { |
| 368 | "Cache-Control": "no-cache", | 538 | "Cache-Control": "no-cache", |
| 369 | @@ -389,10 +547,16 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => { | 539 | @@ -389,10 +663,16 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => { |
| 370 | const url = new URL(request.url); | 540 | const url = new URL(request.url); |
| 371 | if (request.method === "GET" && basePattern.test(url)) { | 541 | if (request.method === "GET" && basePattern.test(url)) { |
| 372 | const sessionId = url.searchParams.get("sessionId") || namespace.newUniqueId().toString(); | 542 | const sessionId = url.searchParams.get("sessionId") || namespace.newUniqueId().toString(); |
| @@ -387,7 +557,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 14 unmodified lines |
| 387 | endpointUrl.pathname = encodeURI(`${basePath}/message`); | 557 | endpointUrl.pathname = encodeURI(`${basePath}/message`); |
| 388 | endpointUrl.searchParams.set("sessionId", sessionId); | 558 | endpointUrl.searchParams.set("sessionId", sessionId); |
| 389 | const endpointMessage = `event: endpoint\ndata: ${endpointUrl.pathname + endpointUrl.search + endpointUrl.hash}\n\n`; | 559 | 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 = {}) => { |
| 391 | console.error("Failed to establish WebSocket connection"); | 561 | console.error("Failed to establish WebSocket connection"); |
| 392 | await writer.close(); | 562 | await writer.close(); |
| 393 | return new Response("Failed to establish WebSocket connection", { status: 500 }); | 563 | return new Response("Failed to establish WebSocket connection", { status: 500 }); |
| @@ -500,15 +670,16 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 106 unmodified lines |
| 500 | console.error("Error closing SSE connection:", error); | 670 | console.error("Error closing SSE connection:", error); |
| 501 | } | 671 | } |
| 502 | } | 672 | } |
| 503 | @@ -586,6 +809,7 @@ var StreamableHTTPServerTransport = class { | 673 | @@ -586,6 +925,8 @@ var StreamableHTTPServerTransport = class { |
| 504 | constructor(options) { | 674 | constructor(options) { |
| 505 | this._started = false; | 675 | this._started = false; |
| 506 | this._streamResponseIds = /* @__PURE__ */ new Map(); | 676 | this._streamResponseIds = /* @__PURE__ */ new Map(); |
| 507 | + this._replayInFlight = /* @__PURE__ */ new Map(); | 677 | + this._replayInFlight = /* @__PURE__ */ new Map(); |
| | 678 | + this._sweptStaleStreams = /* @__PURE__ */ new Set(); |
| 508 | const { agent } = getCurrentAgent(); | 679 | const { agent } = getCurrentAgent(); |
| 509 | if (!agent) throw new Error("McpAgent was not found in Transport constructor"); | 680 | if (!agent) throw new Error("McpAgent was not found in Transport constructor"); |
| 510 | this._agent = agent; | 681 | this._agent = agent; |
| 511 | @@ -627,23 +851,74 @@ var StreamableHTTPServerTransport = class { | 682 | @@ -627,23 +968,146 @@ var StreamableHTTPServerTransport = class { |
| 512 | const resumedStreamId = await this._eventStore.getStreamIdForEventId?.(lastEventId); | 683 | const resumedStreamId = await this._eventStore.getStreamIdForEventId?.(lastEventId); |
| 513 | if (resumedStreamId) { | 684 | if (resumedStreamId) { |
| 514 | const resumeState = { streamId: resumedStreamId }; | 685 | const resumeState = { streamId: resumedStreamId }; |
| @@ -535,6 +706,14 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 20 unmodified lines |
| 535 | + const ackStreamIds = []; | 706 | + const ackStreamIds = []; |
| 536 | + const replayedResponse = await this.replayEvents(lastEventId); | 707 | + const replayedResponse = await this.replayEvents(lastEventId); |
| 537 | + if (resumedStreamId !== STANDALONE_STREAM_ID && replayedResponse) ackStreamIds.push(resumedStreamId); | 708 | + 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); |
| 538 | + // Last-Event-ID identifies one disconnected stream. Keep replay | 717 | + // Last-Event-ID identifies one disconnected stream. Keep replay |
| 539 | + // scoped to that stream: mixing another POST's response into this | 718 | + // scoped to that stream: mixing another POST's response into this |
| 540 | + // recovery stream can make a client stop reconnecting before the | 719 | + // recovery stream can make a client stop reconnecting before the |
| @@ -565,12 +744,17 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 24 unmodified lines |
| 565 | }; | 744 | }; |
| 566 | connection.setState(standaloneState); | 745 | connection.setState(standaloneState); |
| 567 | + const replayedStreamIds = await this.replayUndeliveredResponsesOnFreshGet(agent, connection); | 746 | + 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); |
| 568 | + // A GET without Last-Event-ID has no identified stream to preserve, so | 752 | + // A GET without Last-Event-ID has no identified stream to preserve, so |
| 569 | + // retain the existing fallback that drains completed POST responses. | 753 | + // retain the existing fallback that drains completed POST responses. |
| 570 | + // Same delivery-confirmed clearing as the resume branch above. When | 754 | + // Same delivery-confirmed clearing as the resume branch above. When |
| 571 | + // nothing was replayed no close frame is sent and this stays the | 755 | + // nothing was replayed or swept no close frame is sent and this stays |
| 572 | + // session's long-lived standalone listener. | 756 | + // the session's long-lived standalone listener. |
| 573 | + if (replayedStreamIds.length > 0) this.sendReplayComplete(connection, replayedStreamIds); | 757 | + if (replayedStreamIds.length > 0 || staleStreamIds.length > 0) this.sendReplayComplete(connection, [...replayedStreamIds, ...staleStreamIds]); |
| 574 | + } | 758 | + } |
| 575 | + /** | 759 | + /** |
| 576 | + * Ask the Worker bridge to flush everything queued on `connection`, | 760 | + * Ask the Worker bridge to flush everything queued on `connection`, |
| @@ -586,10 +770,69 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 9 unmodified lines |
| 586 | + ackStreamIds, | 770 | + ackStreamIds, |
| 587 | + close: true | 771 | + close: true |
| 588 | + })); | 772 | + })); |
| | 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; |
| 589 | } | 832 | } |
| 590 | /** | 833 | /** |
| 591 | * Close any connection (other than `selfId`) currently bound to | 834 | * 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 { |
| 593 | * Closing rather than mutating sibling state mirrors how the SDK's | 836 | * Closing rather than mutating sibling state mirrors how the SDK's |
| 594 | * single `_streamMapping` entry gives last-writer-wins for free, and | 837 | * single `_streamMapping` entry gives last-writer-wins for free, and |
| 595 | * keeps `send()` from routing to a stale bridge. | 838 | * keeps `send()` from routing to a stale bridge. |
| @@ -599,7 +842,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 3 unmodified lines |
| 599 | */ | 842 | */ |
| 600 | supersedePriorStreamConnections(agent, selfId, streamId) { | 843 | supersedePriorStreamConnections(agent, selfId, streamId) { |
| 601 | for (const other of agent.getConnections()) { | 844 | for (const other of agent.getConnections()) { |
| 602 | @@ -664,12 +942,14 @@ var StreamableHTTPServerTransport = class { | 845 | @@ -664,12 +1131,14 @@ var StreamableHTTPServerTransport = class { |
| 603 | * Only used when resumability is enabled | 846 | * Only used when resumability is enabled |
| 604 | */ | 847 | */ |
| 605 | async replayEvents(lastEventId) { | 848 | async replayEvents(lastEventId) { |
| @@ -615,7 +858,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 9 unmodified lines |
| 615 | this.writeSSEEvent(connection, message, eventId); | 858 | this.writeSSEEvent(connection, message, eventId); |
| 616 | } catch (error) { | 859 | } catch (error) { |
| 617 | this.onerror?.(error); | 860 | this.onerror?.(error); |
| 618 | @@ -678,6 +958,45 @@ var StreamableHTTPServerTransport = class { | 861 | @@ -678,6 +1147,45 @@ var StreamableHTTPServerTransport = class { |
| 619 | } catch (error) { | 862 | } catch (error) { |
| 620 | this.onerror?.(error); | 863 | this.onerror?.(error); |
| 621 | } | 864 | } |
| @@ -661,12 +904,16 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 39 unmodified lines |
| 661 | } | 904 | } |
| 662 | /** | 905 | /** |
| 663 | * Writes an event to the SSE stream with proper formatting | 906 | * 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 { |
| 665 | return connection.send(JSON.stringify({ | 908 | return connection.send(JSON.stringify({ |
| 666 | type: "cf_mcp_agent_event", | 909 | type: "cf_mcp_agent_event", |
| 667 | event: eventData, | 910 | event: eventData, |
| 668 | + eventId, | 911 | + eventId, |
| 669 | + streamId: eventId ? eventId.slice(0, eventId.lastIndexOf(":")) : void 0, | 912 | + 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, |
| 670 | close | 917 | close |
| 671 | })); | 918 | })); |
| 672 | } | 919 | } |
| @@ -727,7 +974,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 54 unmodified lines |
| 727 | * Handles POST requests containing JSON-RPC messages | 974 | * Handles POST requests containing JSON-RPC messages |
| 728 | */ | 975 | */ |
| 729 | async handlePostRequest(req, parsedBody) { | 976 | async handlePostRequest(req, parsedBody) { |
| 730 | @@ -733,6 +1107,22 @@ var StreamableHTTPServerTransport = class { | 977 | @@ -733,6 +1300,22 @@ var StreamableHTTPServerTransport = class { |
| 731 | }; | 978 | }; |
| 732 | connection.setState(postState); | 979 | connection.setState(postState); |
| 733 | if (this._eventStore) await agent.setStreamRequestIds(streamId, requestIds); | 980 | if (this._eventStore) await agent.setStreamRequestIds(streamId, requestIds); |
| @@ -750,7 +997,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 16 unmodified lines |
| 750 | for (const message of messages) { | 997 | for (const message of messages) { |
| 751 | if (this.messageInterceptor) { | 998 | if (this.messageInterceptor) { |
| 752 | if (await this.messageInterceptor(message, { | 999 | if (await this.messageInterceptor(message, { |
| 753 | @@ -760,7 +1150,22 @@ var StreamableHTTPServerTransport = class { | 1000 | @@ -760,7 +1343,22 @@ var StreamableHTTPServerTransport = class { |
| 754 | * when the originating WS has dropped. | 1001 | * when the originating WS has dropped. |
| 755 | */ | 1002 | */ |
| 756 | async sendOnStream(agent, streamId, relatedIds, liveConnection, message, requestId) { | 1003 | async sendOnStream(agent, streamId, relatedIds, liveConnection, message, requestId) { |
| @@ -774,7 +1021,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 17 unmodified lines |
| 774 | let shouldClose = false; | 1021 | let shouldClose = false; |
| 775 | if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { | 1022 | if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { |
| 776 | let responseIds = this._streamResponseIds.get(streamId); | 1023 | let responseIds = this._streamResponseIds.get(streamId); |
| 777 | @@ -777,9 +1182,11 @@ var StreamableHTTPServerTransport = class { | 1024 | @@ -777,9 +1375,11 @@ var StreamableHTTPServerTransport = class { |
| 778 | } catch (error) { | 1025 | } catch (error) { |
| 779 | this.onerror?.(error); | 1026 | this.onerror?.(error); |
| 780 | } | 1027 | } |
| @@ -788,7 +1035,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 7 unmodified lines |
| 788 | } | 1035 | } |
| 789 | } | 1036 | } |
| 790 | async send(message, options) { | 1037 | async send(message, options) { |
| 791 | @@ -798,14 +1205,19 @@ var StreamableHTTPServerTransport = class { | 1038 | @@ -798,14 +1398,19 @@ var StreamableHTTPServerTransport = class { |
| 792 | * | 1039 | * |
| 793 | * Sent on exactly one stream, per MCP: "the server MUST send each of | 1040 | * Sent on exactly one stream, per MCP: "the server MUST send each of |
| 794 | * its JSON-RPC messages on only one of the connected streams; it MUST | 1041 | * its JSON-RPC messages on only one of the connected streams; it MUST |
| @@ -812,7 +1059,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 17 unmodified lines |
| 812 | if (standalone) this.writeSSEEvent(standalone, message, eventId); | 1059 | if (standalone) this.writeSSEEvent(standalone, message, eventId); |
| 813 | } | 1060 | } |
| 814 | /** | 1061 | /** |
| 815 | @@ -861,12 +1273,10 @@ var StreamableHTTPServerTransport = class { | 1062 | @@ -861,12 +1466,10 @@ var StreamableHTTPServerTransport = class { |
| 816 | * | 1063 | * |
| 817 | * ## Lifecycle | 1064 | * ## Lifecycle |
| 818 | * | 1065 | * |
| @@ -829,7 +1076,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 10 unmodified lines |
| 829 | * | 1076 | * |
| 830 | * Standalone GET stream events (`_GET_stream`) are *not* cleared | 1077 | * Standalone GET stream events (`_GET_stream`) are *not* cleared |
| 831 | * automatically; they accumulate for the lifetime of the DO. Bounded | 1078 | * 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 { |
| 833 | } | 1080 | } |
| 834 | async storeEvent(streamId, message) { | 1081 | async storeEvent(streamId, message) { |
| 835 | if (streamId.includes(":")) throw new Error(`DurableObjectEventStore: streamId must not contain ':' (got ${JSON.stringify(streamId)})`); | 1082 | 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 |
| 864 | return eventId; | 1111 | return eventId; |
| 865 | } | 1112 | } |
| 866 | async getStreamIdForEventId(eventId) { | 1113 | async getStreamIdForEventId(eventId) { |
| 867 | @@ -915,9 +1347,59 @@ var DurableObjectEventStore = class DurableObjectEventStore { | 1114 | @@ -915,9 +1540,59 @@ var DurableObjectEventStore = class DurableObjectEventStore { |
| 868 | start: startKey, | 1115 | start: startKey, |
| 869 | limit: DurableObjectEventStore.REPLAY_LIMIT | 1116 | limit: DurableObjectEventStore.REPLAY_LIMIT |
| 870 | }); | 1117 | }); |
| @@ -925,7 +1172,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 54 unmodified lines |
| 925 | /** | 1172 | /** |
| 926 | * Drop the event log for a single stream. Called by the transport | 1173 | * Drop the event log for a single stream. Called by the transport |
| 927 | * immediately after a POST's final response has been written to the | 1174 | * 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__:"; |
| 929 | DurableObjectEventStore.SEQ_PAD = 16; | 1176 | DurableObjectEventStore.SEQ_PAD = 16; |
| 930 | DurableObjectEventStore.DELETE_CHUNK = 128; | 1177 | DurableObjectEventStore.DELETE_CHUNK = 128; |
| 931 | DurableObjectEventStore.REPLAY_LIMIT = 1e3; | 1178 | DurableObjectEventStore.REPLAY_LIMIT = 1e3; |
| @@ -939,7 +1186,106 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 7 unmodified lines |
| 939 | //#endregion | 1186 | //#endregion |
| 940 | //#region src/mcp/client-transports.ts | 1187 | //#region src/mcp/client-transports.ts |
| 941 | /** | 1188 | /** |
| 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 */ |
| 943 | async deleteStreamRequestIds(streamId) { | 1289 | async deleteStreamRequestIds(streamId) { |
| 944 | await this.ctx.storage.delete(`${McpAgent.STREAM_REQS_KEY_PREFIX}${streamId}`); | 1290 | await this.ctx.storage.delete(`${McpAgent.STREAM_REQS_KEY_PREFIX}${streamId}`); |
| 945 | } | 1291 | } |
| @@ -962,10 +1308,14 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 16 unmodified lines |
| 962 | + prefix: McpAgent.STREAM_REQS_KEY_PREFIX, | 1308 | + prefix: McpAgent.STREAM_REQS_KEY_PREFIX, |
| 963 | + limit: 1e3 | 1309 | + limit: 1e3 |
| 964 | + }); | 1310 | + }); |
| 965 | + return [...rows].flatMap(([key, requestIds]) => Array.isArray(requestIds) && requestIds.length > 0 ? [{ | 1311 | + return [...rows].flatMap(([key, value]) => { |
| 966 | + streamId: key.slice(McpAgent.STREAM_REQS_KEY_PREFIX.length), | 1312 | + const row = readStreamRequestIdsRow(value); |
| 967 | + requestIds | 1313 | + return row && row.requestIds.length > 0 ? [{ |
| 968 | + }] : []); | 1314 | + epoch: row.epoch, |
| | 1315 | + streamId: key.slice(McpAgent.STREAM_REQS_KEY_PREFIX.length), |
| | 1316 | + requestIds: row.requestIds |
| | 1317 | + }] : []; |
| | 1318 | + }); |
| 969 | + } | 1319 | + } |
| 970 | + async acknowledgeDeliveredStream(streamId) { | 1320 | + async acknowledgeDeliveredStream(streamId) { |
| 971 | + await this.deleteStreamRequestIds(streamId); | 1321 | + await this.deleteStreamRequestIds(streamId); |
| @@ -988,7 +1338,39 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 16 unmodified lines |
| 988 | /** | 1338 | /** |
| 989 | * Reverse lookup: find which POST stream a given `requestId` belongs | 1339 | * Reverse lookup: find which POST stream a given `requestId` belongs |
| 990 | * to, and return the stream's full `requestIds` list in the same | 1340 | * 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 { |
| 992 | return; | 1374 | return; |
| 993 | } | 1375 | } |
| 994 | break; | 1376 | break; |
| @@ -1040,14 +1422,15 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 45 unmodified lines |
| 1040 | } | 1422 | } |
| 1041 | } | 1423 | } |
| 1042 | } | 1424 | } |
| 1043 | @@ -1697,7 +2241,8 @@ var McpAgent = class McpAgent extends Agent { | 1425 | @@ -1697,7 +2525,9 @@ var McpAgent = class McpAgent extends Agent { |
| 1044 | } | 1426 | } |
| 1045 | }; | 1427 | }; |
| 1046 | McpAgent.STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:"; | 1428 | McpAgent.STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:"; |
| 1047 | +McpAgent.UNDELIVERED_STREAM_KEY_PREFIX = "__mcp_undelivered_stream__:"; | 1429 | +McpAgent.UNDELIVERED_STREAM_KEY_PREFIX = "__mcp_undelivered_stream__:"; |
| | 1430 | +McpAgent.SESSION_EPOCH_KEY = "__mcp_session_epoch__"; |
| 1048 | //#endregion | 1431 | //#endregion |
| 1049 | -export { DurableObjectEventStore, ElicitRequestSchema, MCP_SERVER_ID_MAX_LENGTH, McpAgent, RPCClientTransport, RPCServerTransport, RPC_DO_PREFIX, SSEEdgeClientTransport, StreamableHTTPEdgeClientTransport, WorkerTransport, createMcpHandler, experimental_createMcpHandler, getMcpAuthContext, normalizeServerId }; | 1432 | -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 }; |
| 1051 | | 1434 | |
| 1052 | //# sourceMappingURL=index.js.map | 1435 | //# sourceMappingURL=index.js.map |
| 1053 | \ No newline at end of file | 1436 | \ No newline at end of file |