diff --git a/dist/mcp/index.d.ts b/dist/mcp/index.d.ts index c8fad448e8797b89690a99d93490d1363851b225..77f9fe3f6f2375eadc9f7a2974f0d202fe75b3bd 100644 --- a/dist/mcp/index.d.ts +++ b/dist/mcp/index.d.ts @@ -29,6 +29,7 @@ import { xt as MCPClientOAuthCallbackConfig, zt as ElicitResult } from "../agent-tool-types-CNyE1iz_.js"; +declare const MAX_SSE_AGE_MS = 1800000; export { type ClearableEventStore, type CreateMcpHandlerOptions, @@ -41,6 +42,7 @@ export { type MCPConnectionResult, type MCPDiscoverResult, type MCPServerOptions, + MAX_SSE_AGE_MS, MCP_SERVER_ID_MAX_LENGTH, McpAgent, type McpAuthContext, diff --git a/dist/mcp/index.js b/dist/mcp/index.js index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..32d52598b42a4062d09020f0a1abfd67dbf3c266 100644 --- a/dist/mcp/index.js +++ b/dist/mcp/index.js @@ -28,13 +28,17 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/ const KEEPALIVE_INTERVAL_MS = 25e3; /** SSE comment frame the parser drops before any event dispatch. */ const KEEPALIVE_FRAME = ": keepalive\n\n"; +// Max age is a stalled-client memory backstop, not session idleness. Keep it +// well above Executor's 5 minute session idle timeout so active clients rarely +// rotate. +const MAX_SSE_AGE_MS = 30 * 60 * 1000; /** * Start an SSE keepalive on `writer`. Returns a `clearInterval` handle * that the stream cleanup must invoke when the stream closes. */ -function startKeepalive(writer, encoder) { +function startKeepalive(writeFrame, encoder) { const handle = setInterval(() => { - writer.write(encoder.encode(KEEPALIVE_FRAME)).catch(() => clearInterval(handle)); + writeFrame(encoder.encode(KEEPALIVE_FRAME)); }, KEEPALIVE_INTERVAL_MS); return handle; } @@ -180,10 +184,15 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { }); return new Response(body, { status: 404 }); } - const { readable, writable } = new TransformStream(); - const writer = writable.getWriter(); - const encoder = new TextEncoder(); - const existingHeaders = {}; + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + const encoder = new TextEncoder(); + const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024; + let __pendingBytes = 0; + let __writeChain = Promise.resolve(); + let __sseClosed = false; + let keepAlive; + const existingHeaders = {}; request.headers.forEach((value, key) => { existingHeaders[key] = value; }); @@ -206,45 +215,99 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { jsonrpc: "2.0" }); return new Response(body, { status: 500 }); - } - ws.accept(); - if (messages.every((msg) => isJSONRPCNotification(msg) || isJSONRPCResultResponse(msg))) { - ws.close(); - return new Response(null, { + } + ws.accept(); + const __closeSse = () => { + if (__sseClosed) return; + __sseClosed = true; + try { + clearInterval(keepAlive); + } catch {} + try { + ws.close(1013, "SSE client not draining"); + } catch {} + writer.abort(new Error("SSE client not draining")).catch(() => {}); + }; + const __markSseClientClosed = () => { + if (__sseClosed) return; + __sseClosed = true; + try { + clearInterval(keepAlive); + } catch {} + writer.abort(new Error("SSE client disconnected")).catch(() => {}); + }; + writer.closed?.catch(() => { + __markSseClientClosed(); + }); + request.signal.addEventListener("abort", __closeSse, { once: true }); + const __forwardSse = (frame) => { + if (__sseClosed) return __writeChain; + if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) { + __closeSse(); + return Promise.resolve(); + } + __pendingBytes += frame.byteLength; + __writeChain = __writeChain.then(() => writer.write(frame)).catch(() => { + __closeSse(); + }).finally(() => { + __pendingBytes -= frame.byteLength; + }); + return __writeChain; + }; + keepAlive = startKeepalive(__forwardSse, encoder); + if (messages.every((msg) => isJSONRPCNotification(msg) || isJSONRPCResultResponse(msg))) { + clearInterval(keepAlive); + ws.close(); + return new Response(null, { headers: corsHeaders(request, options.corsOptions), status: 202 - }); - } - const keepAlive = startKeepalive(writer, encoder); - ws.addEventListener("message", (event) => { + }); + } + ws.addEventListener("message", (event) => { async function onMessage(event) { try { - const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data); - const message = JSON.parse(data); - if (message.type !== "cf_mcp_agent_event") return; - await writer.write(encoder.encode(message.event)); - if (message.close) { - clearInterval(keepAlive); - ws?.close(); - await writer.close().catch(() => {}); - } + const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data); + const message = JSON.parse(data); + if (message.type !== "cf_mcp_agent_event") return; + const writePromise = __forwardSse(encoder.encode(message.event)); + if (message.close) { + clearInterval(keepAlive); + await writePromise; + await writer.close(); + if (!__sseClosed && !request.signal.aborted) { + // workerd resolves writer.close() even when the client + // canceled the POST response body, and request.signal does + // not reliably fire for that cancellation, so a successful + // close is NOT proof of delivery. Never ack POST-stream + // deliveries: the DO keeps the response persisted and the + // client's own reconnect GET replays and acks it. A client + // that DID receive the result closes the POST body reader + // without a Last-Event-ID reconnect, and the SDK drops + // responses for request ids it no longer tracks, so the + // worst case of this at-least-once choice is a benign + // replay to a fresh GET, not a wedged tool call. + ws?.close(1000, "SSE response delivered"); + } + } } catch (error) { console.error("Error forwarding message to SSE:", error); } } onMessage(event).catch(console.error); }); - ws.addEventListener("error", (error) => { - async function onError(_error) { - clearInterval(keepAlive); - await writer.close().catch(() => {}); + ws.addEventListener("error", (error) => { + async function onError(_error) { + __sseClosed = true; + clearInterval(keepAlive); + await writer.close().catch(() => {}); } onError(error).catch(console.error); }); - ws.addEventListener("close", () => { - async function onClose() { - clearInterval(keepAlive); - await writer.close().catch(() => {}); + ws.addEventListener("close", () => { + async function onClose() { + __sseClosed = true; + clearInterval(keepAlive); + await writer.close().catch(() => {}); } onClose().catch(console.error); }); @@ -279,10 +342,16 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { id: null, jsonrpc: "2.0" }), { status: 400 }); - const { readable, writable } = new TransformStream(); - const writer = writable.getWriter(); - const encoder = new TextEncoder(); - const agent = await getAgentByName(namespace, `streamable-http:${sessionId}`, { + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + const encoder = new TextEncoder(); + const __openedAt = Date.now(); + const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024; + let __pendingBytes = 0; + let __writeChain = Promise.resolve(); + let __sseClosed = false; + let keepAlive; + const agent = await getAgentByName(namespace, `streamable-http:${sessionId}`, { props: ctx.props, jurisdiction: options.jurisdiction }); @@ -306,27 +375,116 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => { if (!ws) { await writer.close(); return new Response("Failed to establish WS to DO", { status: 500 }); - } - ws.accept(); - ws.addEventListener("message", (event) => { + } + ws.accept(); + const __abortSse = () => { + if (__sseClosed) return; + __sseClosed = true; + try { + clearInterval(keepAlive); + } catch {} + try { + ws.close(1013, "SSE client not draining"); + } catch {} + writer.abort(new Error("SSE client not draining")).catch(() => {}); + }; + writer.closed?.catch(() => { + __abortSse(); + }); + request.signal.addEventListener("abort", __abortSse, { once: true }); + const __closeSseGracefully = () => { + if (__sseClosed) return __writeChain; + __sseClosed = true; + try { + clearInterval(keepAlive); + } catch {} + try { + const finalFrame = encoder.encode(": max-age rotation, reconnect\n\n"); + __writeChain = __writeChain.then(() => writer.write(finalFrame)).catch(() => {}).then(() => writer.close()).catch(() => { + writer.abort().catch(() => {}); + }); + } catch { + __writeChain = writer.close().catch(() => { + writer.abort().catch(() => {}); + }); + } + try { + ws.close(1000, "sse_max_age_rotation"); + } catch {} + return __writeChain; + }; + const __forwardSse = (frame) => { + if (__sseClosed) return __writeChain; + const ageMs = Date.now() - __openedAt; + if (ageMs > MAX_SSE_AGE_MS) { + console.log(JSON.stringify({ + event: "sse_max_age_close", + sessionId, + variant: "streamable-get", + ageMs, + pendingBytes: __pendingBytes + })); + __closeSseGracefully(); + return Promise.resolve(); + } + if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) { + __abortSse(); + return Promise.resolve(); + } + __pendingBytes += frame.byteLength; + __writeChain = __writeChain.then(() => writer.write(frame)).catch(() => { + __abortSse(); + }).finally(() => { + __pendingBytes -= frame.byteLength; + }); + return __writeChain; + }; + keepAlive = startKeepalive(__forwardSse, encoder); + ws.addEventListener("message", (event) => { try { async function onMessage(ev) { - const data = typeof ev.data === "string" ? ev.data : new TextDecoder().decode(ev.data); - const message = JSON.parse(data); - if (message.type !== "cf_mcp_agent_event") return; - await writer.write(encoder.encode(message.event)); - } + const data = typeof ev.data === "string" ? ev.data : new TextDecoder().decode(ev.data); + const message = JSON.parse(data); + if (message.type !== "cf_mcp_agent_event") return; + const writePromise = __forwardSse(encoder.encode(message.event)); + if (message.close) { + clearInterval(keepAlive); + await writePromise; + await writer.close(); + if (!__sseClosed && !request.signal.aborted) { + // Storage is cleared only on this ack, which fires only + // after writer.close() resolved with the client still + // attached; a replayed response enqueued into a dead GET + // never acks and stays replayable. `ackStreamIds` lets a + // replay-complete frame confirm several replayed streams + // at once; a live final response carries its `streamId`. + const ackStreamIds = Array.isArray(message.ackStreamIds) ? message.ackStreamIds : message.streamId ? [message.streamId] : []; + for (const ackStreamId of ackStreamIds) try { + ws.send(JSON.stringify({ + type: "cf_mcp_delivery_ack", + eventId: message.eventId, + streamId: ackStreamId + })); + } catch {} + ws?.close(1000, "SSE response delivered"); + } + } + } onMessage(event).catch(console.error); } catch (e) { console.error("Error forwarding message to SSE:", e); } - }); - ws.addEventListener("error", () => { - writer.close().catch(() => {}); - }); - ws.addEventListener("close", () => { - writer.close().catch(() => {}); - }); + }); + ws.addEventListener("error", () => { + __sseClosed = true; + clearInterval(keepAlive); + writer.close().catch(() => {}); + }); + ws.addEventListener("close", () => { + __sseClosed = true; + clearInterval(keepAlive); + writer.close().catch(() => {}); + }); return new Response(readable, { headers: { "Cache-Control": "no-cache", @@ -389,10 +547,16 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => { const url = new URL(request.url); if (request.method === "GET" && basePattern.test(url)) { const sessionId = url.searchParams.get("sessionId") || namespace.newUniqueId().toString(); - const { readable, writable } = new TransformStream(); - const writer = writable.getWriter(); - const encoder = new TextEncoder(); - const endpointUrl = new URL(request.url); + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + const encoder = new TextEncoder(); + const __openedAt = Date.now(); + const MAX_PENDING_SSE_BYTES = 8 * 1024 * 1024; + let __pendingBytes = 0; + let __writeChain = Promise.resolve(); + let __sseClosed = false; + let keepAlive; + const endpointUrl = new URL(request.url); endpointUrl.pathname = encodeURI(`${basePath}/message`); endpointUrl.searchParams.set("sessionId", sessionId); const endpointMessage = `event: endpoint\ndata: ${endpointUrl.pathname + endpointUrl.search + endpointUrl.hash}\n\n`; @@ -414,35 +578,94 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => { console.error("Failed to establish WebSocket connection"); await writer.close(); return new Response("Failed to establish WebSocket connection", { status: 500 }); - } - ws.accept(); - ws.addEventListener("message", (event) => { + } + ws.accept(); + const __abortSse = () => { + __sseClosed = true; + try { + clearInterval(keepAlive); + } catch {} + try { + ws.close(1013, "SSE client not draining"); + } catch {} + writer.abort(new Error("SSE client not draining")).catch(() => {}); + }; + const __closeSseGracefully = () => { + __sseClosed = true; + try { + clearInterval(keepAlive); + } catch {} + try { + const finalFrame = encoder.encode(": max-age rotation, reconnect\n\n"); + __writeChain = __writeChain.then(() => writer.write(finalFrame)).catch(() => {}).then(() => writer.close()).catch(() => { + writer.abort().catch(() => {}); + }); + } catch { + __writeChain = writer.close().catch(() => { + writer.abort().catch(() => {}); + }); + } + try { + ws.close(1000, "sse_max_age_rotation"); + } catch {} + return __writeChain; + }; + const __forwardSse = (frame) => { + if (__sseClosed) return __writeChain; + const ageMs = Date.now() - __openedAt; + if (ageMs > MAX_SSE_AGE_MS) { + console.log(JSON.stringify({ + event: "sse_max_age_close", + sessionId, + variant: "legacy-sse", + ageMs, + pendingBytes: __pendingBytes + })); + __closeSseGracefully(); + return Promise.resolve(); + } + if (__pendingBytes + frame.byteLength > MAX_PENDING_SSE_BYTES) { + __abortSse(); + return Promise.resolve(); + } + __pendingBytes += frame.byteLength; + __writeChain = __writeChain.then(() => writer.write(frame)).catch(() => {}).finally(() => { + __pendingBytes -= frame.byteLength; + }); + return __writeChain; + }; + keepAlive = startKeepalive(__forwardSse, encoder); + ws.addEventListener("message", (event) => { async function onMessage(event) { try { const message = JSON.parse(event.data); - const result = JSONRPCMessageSchema.safeParse(message); - if (!result.success) return; - const messageText = `event: message\ndata: ${JSON.stringify(result.data)}\n\n`; - await writer.write(encoder.encode(messageText)); - } catch (error) { + const result = JSONRPCMessageSchema.safeParse(message); + if (!result.success) return; + const messageText = `event: message\ndata: ${JSON.stringify(result.data)}\n\n`; + __forwardSse(encoder.encode(messageText)); + } catch (error) { console.error("Error forwarding message to SSE:", error); } } onMessage(event).catch(console.error); }); - ws.addEventListener("error", (error) => { - async function onError(_error) { - try { - await writer.close(); - } catch (_e) {} + ws.addEventListener("error", (error) => { + async function onError(_error) { + try { + __sseClosed = true; + clearInterval(keepAlive); + await writer.close(); + } catch (_e) {} } onError(error).catch(console.error); }); - ws.addEventListener("close", () => { - async function onClose() { - try { - await writer.close(); - } catch (error) { + ws.addEventListener("close", () => { + async function onClose() { + try { + __sseClosed = true; + clearInterval(keepAlive); + await writer.close(); + } catch (error) { console.error("Error closing SSE connection:", error); } } @@ -634,7 +857,23 @@ var StreamableHTTPServerTransport = class { } this.supersedePriorStreamConnections(agent, connection.id, resumedStreamId); connection.setState(resumeState); - await this.replayEvents(lastEventId); + const ackStreamIds = []; + const replayedResponse = await this.replayEvents(lastEventId); + if (resumedStreamId !== STANDALONE_STREAM_ID && replayedResponse) ackStreamIds.push(resumedStreamId); + // A reconnect can carry a Last-Event-ID for an already-delivered + // stream (e.g. the initialize response) while a tool result + // completed on a since-abandoned POST stream. Replay those other + // undelivered responses on this connection too, otherwise they are + // stranded until the session is torn down. + ackStreamIds.push(...await this.replayUndeliveredResponses(agent, connection, resumedStreamId)); + // Storage is NOT cleared here: replayed events are only enqueued + // on the WS bridge, and workerd cannot tell a dead client from a + // live one at write time. The close frame below makes the bridge + // drain the writes, close the HTTP response, and send one + // cf_mcp_delivery_ack per replayed stream only if the client was + // still attached; McpAgent.onMessage clears storage on that ack. + // A dead recovery GET therefore leaves everything replayable. + if (ackStreamIds.length > 0) this.sendReplayComplete(connection, ackStreamIds); return; } } @@ -644,6 +883,26 @@ var StreamableHTTPServerTransport = class { _standaloneSse: true }; connection.setState(standaloneState); + const replayedStreamIds = await this.replayUndeliveredResponses(agent, connection); + // Same delivery-confirmed clearing as the resume branch above. When + // nothing was replayed no close frame is sent and this connection stays + // open as the session's long-lived standalone listener. + if (replayedStreamIds.length > 0) this.sendReplayComplete(connection, replayedStreamIds); + } + /** + * Ask the Worker bridge to flush everything queued on `connection`, + * close the client-facing SSE response, and, only if the writer close + * completed with the client still attached, echo one + * `cf_mcp_delivery_ack` per stream id in `ackStreamIds`. The event + * payload is an SSE comment so client parsers drop it. + */ + sendReplayComplete(connection, ackStreamIds) { + return connection.send(JSON.stringify({ + type: "cf_mcp_agent_event", + event: ": replay-complete\n\n", + ackStreamIds, + close: true + })); } /** * Close any connection (other than `selfId`) currently bound to @@ -664,12 +923,14 @@ var StreamableHTTPServerTransport = class { * Only used when resumability is enabled */ async replayEvents(lastEventId) { - if (!this._eventStore) return; + if (!this._eventStore) return false; const { connection } = getCurrentAgent(); if (!connection) throw new Error("Connection was not available in replayEvents"); + let replayedResponse = false; try { await this._eventStore?.replayEventsAfter(lastEventId, { send: async (eventId, message) => { try { + if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) replayedResponse = true; this.writeSSEEvent(connection, message, eventId); } catch (error) { this.onerror?.(error); @@ -678,6 +939,33 @@ var StreamableHTTPServerTransport = class { } catch (error) { this.onerror?.(error); } + return replayedResponse; + } + /** + * Enqueue every undelivered stream's events on `connection` and return + * the stream ids whose replay included a response. Deliberately does + * NOT clear storage: the caller sends a replay-complete close frame and + * the bridge acks each stream only after the client-facing writer + * drained and closed with the client still attached. + */ + async replayUndeliveredResponses(agent, connection, skipStreamId) { + const replayedStreamIds = []; + if (!this._eventStore?.replayEventsForStream) return replayedStreamIds; + const streamIds = await agent.getUndeliveredStreamIds(); + for (const streamId of streamIds) { + if (skipStreamId !== void 0 && streamId === skipStreamId) continue; + let replayedResponse = false; + await this._eventStore.replayEventsForStream(streamId, { send: async (eventId, message) => { + try { + if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) replayedResponse = true; + this.writeSSEEvent(connection, message, eventId); + } catch (error) { + this.onerror?.(error); + } + } }); + if (replayedResponse) replayedStreamIds.push(streamId); + } + return replayedStreamIds; } /** * Writes an event to the SSE stream with proper formatting @@ -689,10 +977,65 @@ var StreamableHTTPServerTransport = class { return connection.send(JSON.stringify({ type: "cf_mcp_agent_event", event: eventData, + eventId, + streamId: eventId ? eventId.slice(0, eventId.lastIndexOf(":")) : void 0, close })); } /** + * Persist and write the priming SSE event for a freshly opened POST + * stream. The stored message is a benign JSON-RPC notification so that + * if a plain recovery GET ever replays this stream via + * {@link writeSSEEvent} (which forces `event: message`), a compliant + * client parses and ignores it rather than erroring. The live priming + * frame, however, uses a non-`message` SSE event type so the SDK + * records the id (setting hasPrimingEvent / lastEventId) WITHOUT + * dispatching it as a JSON-RPC message: its SSE loop skips any event + * whose type is not `message`. A data line is required because the + * SDK's SSE parser drops events with empty data before recording the + * id, so an id-only frame would not prime. + */ + async emitPrimingEvent(agent, connection, streamId) { + if (!this._eventStore) return; + const primingMessage = { + jsonrpc: "2.0", + method: "notifications/message", + params: { + level: "debug", + data: "mcp-stream-priming" + } + }; + let eventId; + try { + eventId = await this._eventStore.storeEvent(streamId, primingMessage); + } catch (error) { + this.onerror?.(error); + return; + } + if (!eventId) return; + try { + this.writePrimingSSEEvent(connection, primingMessage, eventId); + } catch (error) { + this.onerror?.(error); + } + } + /** + * Write a priming SSE frame: `event: mcp-priming`, an `id:` carrying a + * real replayable event-store id, and a data line the SDK ignores + * because the event type is not `message`. Never sets `close`. + */ + writePrimingSSEEvent(connection, message, eventId) { + let eventData = "event: mcp-priming\n"; + eventData += `id: ${eventId}\n`; + eventData += `data: ${JSON.stringify(message)}\n\n`; + return connection.send(JSON.stringify({ + type: "cf_mcp_agent_event", + event: eventData, + eventId, + streamId: eventId.slice(0, eventId.lastIndexOf(":")) + })); + } + /** * Handles POST requests containing JSON-RPC messages */ async handlePostRequest(req, parsedBody) { @@ -733,6 +1076,22 @@ var StreamableHTTPServerTransport = class { }; connection.setState(postState); if (this._eventStore) await agent.setStreamRequestIds(streamId, requestIds); + // Emit a priming SSE event as the very first frame on this POST + // response stream, before dispatching the request(s). The MCP TS SDK + // only auto-reconnects a dropped POST SSE stream when it saw an event + // `id:` before the drop (hasPrimingEvent). Without this, a network + // blip or edge close mid-call leaves the SDK with hasPrimingEvent + // false, so it never issues the recovery GET that replays the + // persisted result, and callTool hangs forever. The priming event is + // a real event-store entry so its id sorts BEFORE the eventual + // response; a reconnect with `last-event-id: ` replays + // everything after it (i.e. the result). See patches/agents patch. + // Scoped to tools/call streams: reconnect-with-replay only matters + // where the result can outlive the connection (long-running tool + // calls). initialize/tools/list resolve in milliseconds and clients + // retry them; priming those streams adds a storage write and an + // extra SSE frame per request for nothing. + if (messages.some((message) => isJSONRPCRequest(message) && message.method === "tools/call")) await this.emitPrimingEvent(agent, connection, streamId); for (const message of messages) { if (this.messageInterceptor) { if (await this.messageInterceptor(message, { @@ -777,9 +1136,11 @@ var StreamableHTTPServerTransport = class { } catch (error) { this.onerror?.(error); } - if (shouldClose) { + if (shouldClose && !this._eventStore) { + await agent.deleteStreamRequestIds(streamId); + } else if (shouldClose) { + await agent.markStreamUndelivered(streamId); await agent.deleteStreamRequestIds(streamId); - if (this._eventStore && isClearableEventStore(this._eventStore)) await this._eventStore.clearStream(streamId); } } async send(message, options) { @@ -861,12 +1222,10 @@ var StreamableHTTPServerTransport = class { * * ## Lifecycle * -* Each POST tool-call stream's events live only until the final -* response is delivered. The transport calls {@link clearStream} -* immediately after writing the close frame, so storage growth is -* bounded by the in-flight POST streams plus the standalone GET -* stream. There is no background sweep — quiescent agents do no work, -* and the DO itself dies with the session. +* Each POST tool-call stream's response events live until the Worker +* bridge confirms the final SSE write or a reconnect GET replays them. +* Per-stream storage is capped at 64 events or roughly 2 MB; oldest +* entries are evicted first. * * Standalone GET stream events (`_GET_stream`) are *not* cleared * automatically; they accumulate for the lifetime of the DO. Bounded @@ -899,6 +1258,7 @@ var DurableObjectEventStore = class DurableObjectEventStore { const eventId = `${streamId}:${seq.toString(16).padStart(DurableObjectEventStore.SEQ_PAD, "0")}`; const eventKey = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${eventId}`; await this.storage.put(eventKey, message); + await this.trimStream(streamId); return eventId; } async getStreamIdForEventId(eventId) { @@ -915,9 +1275,59 @@ var DurableObjectEventStore = class DurableObjectEventStore { start: startKey, limit: DurableObjectEventStore.REPLAY_LIMIT }); - for (const [key, message] of rows) await send(key.slice(DurableObjectEventStore.EVENT_KEY_PREFIX.length), message); + for (const [key, message] of rows) try { + await send(key.slice(DurableObjectEventStore.EVENT_KEY_PREFIX.length), message); + } catch {} return streamId; } + async replayEventsForStream(streamId, { send }) { + const prefix = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${streamId}:`; + const rows = await this.storage.list({ + prefix, + limit: DurableObjectEventStore.REPLAY_LIMIT + }); + for (const [key, message] of rows) try { + await send(key.slice(DurableObjectEventStore.EVENT_KEY_PREFIX.length), message); + } catch {} + return streamId; + } + async trimStream(streamId) { + const prefix = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${streamId}:`; + const rows = await this.storage.list({ + prefix, + limit: DurableObjectEventStore.REPLAY_LIMIT + }); + let totalBytes = 0; + const entries = [...rows].map(([key, message]) => { + let bytes = DurableObjectEventStore.MAX_EVENT_BYTES; + try { + bytes = new TextEncoder().encode(JSON.stringify(message)).byteLength; + } catch {} + totalBytes += bytes; + return { key, bytes }; + }); + const deleteKeys = []; + // Never evict the newest entry: trimStream runs right after storeEvent + // put it, and for a final tool response it is the only copy a recovery + // GET can replay. An oversize final response is kept even past the byte + // cap; the cap then squeezes out older events instead. + while (entries.length > 1 && (entries.length > DurableObjectEventStore.MAX_EVENTS_PER_STREAM || totalBytes > DurableObjectEventStore.MAX_BYTES_PER_STREAM)) { + const evicted = entries.shift(); + if (!evicted) break; + deleteKeys.push(evicted.key); + totalBytes -= evicted.bytes; + } + if (deleteKeys.length > 0) { + console.warn(JSON.stringify({ + event: "mcp_event_store_evicted", + streamId, + evictedCount: deleteKeys.length, + remainingCount: entries.length, + remainingBytes: totalBytes + })); + for (let i = 0; i < deleteKeys.length; i += DurableObjectEventStore.DELETE_CHUNK) await this.storage.delete(deleteKeys.slice(i, i + DurableObjectEventStore.DELETE_CHUNK)); + } + } /** * Drop the event log for a single stream. Called by the transport * immediately after a POST's final response has been written to the @@ -973,6 +1383,9 @@ DurableObjectEventStore.EVENT_KEY_PREFIX = "__mcp_event__:"; DurableObjectEventStore.SEQ_PAD = 16; DurableObjectEventStore.DELETE_CHUNK = 128; DurableObjectEventStore.REPLAY_LIMIT = 1e3; +DurableObjectEventStore.MAX_EVENTS_PER_STREAM = 64; +DurableObjectEventStore.MAX_BYTES_PER_STREAM = 2 * 1024 * 1024; +DurableObjectEventStore.MAX_EVENT_BYTES = 2 * 1024 * 1024; //#endregion //#region src/mcp/client-transports.ts /** @@ -1381,6 +1794,47 @@ var McpAgent = class McpAgent extends Agent { async deleteStreamRequestIds(streamId) { await this.ctx.storage.delete(`${McpAgent.STREAM_REQS_KEY_PREFIX}${streamId}`); } + async markStreamUndelivered(streamId) { + await this.ctx.storage.put(`${McpAgent.UNDELIVERED_STREAM_KEY_PREFIX}${streamId}`, true); + } + async deleteUndeliveredStream(streamId) { + await this.ctx.storage.delete(`${McpAgent.UNDELIVERED_STREAM_KEY_PREFIX}${streamId}`); + } + async getUndeliveredStreamIds() { + const rows = await this.ctx.storage.list({ + prefix: McpAgent.UNDELIVERED_STREAM_KEY_PREFIX, + limit: 1e3 + }); + return [...rows.keys()].map((key) => key.slice(McpAgent.UNDELIVERED_STREAM_KEY_PREFIX.length)); + } + /** List persisted POST stream request ids for replay and idle accounting. @internal */ + async getOpenStreamRequestIds() { + const rows = await this.ctx.storage.list({ + prefix: McpAgent.STREAM_REQS_KEY_PREFIX, + limit: 1e3 + }); + return [...rows].flatMap(([key, requestIds]) => Array.isArray(requestIds) && requestIds.length > 0 ? [{ + streamId: key.slice(McpAgent.STREAM_REQS_KEY_PREFIX.length), + requestIds + }] : []); + } + async acknowledgeDeliveredStream(streamId) { + await this.deleteStreamRequestIds(streamId); + await this.deleteUndeliveredStream(streamId); + const eventStore = this._transport?.["_eventStore"]; + if (eventStore && isClearableEventStore(eventStore)) await eventStore.clearStream(streamId); + } + async onMessage(connection, message) { + if (typeof message !== "string") return super.onMessage(connection, message); + try { + const parsed = JSON.parse(message); + if (parsed?.type === "cf_mcp_delivery_ack" && typeof parsed.streamId === "string") { + await this.acknowledgeDeliveredStream(parsed.streamId); + return; + } + } catch {} + return super.onMessage(connection, message); + } /** * Reverse lookup: find which POST stream a given `requestId` belongs * to, and return the stream's full `requestIds` list in the same @@ -1697,7 +2151,8 @@ var McpAgent = class McpAgent extends Agent { } }; McpAgent.STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:"; +McpAgent.UNDELIVERED_STREAM_KEY_PREFIX = "__mcp_undelivered_stream__:"; //#endregion -export { DurableObjectEventStore, ElicitRequestSchema, MCP_SERVER_ID_MAX_LENGTH, McpAgent, RPCClientTransport, RPCServerTransport, RPC_DO_PREFIX, SSEEdgeClientTransport, StreamableHTTPEdgeClientTransport, WorkerTransport, createMcpHandler, experimental_createMcpHandler, getMcpAuthContext, normalizeServerId }; +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 }; //# sourceMappingURL=index.js.map \ No newline at end of file