History

Route drained Last-Event-ID reconnects to the standalone listener

Rhys Sullivan committed 2 months ago

Commit 05f63cb1 parent 06d7f2f2 files · +53 −13Browse files

Unified · Split

2 files
  1. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +24 −13
  2. packages/hosts/cloudflare/src/mcp/do-event-store.ts +29 −0
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts+24−13
@@ -1215,26 +1215,37 @@ export abstract class McpAgentSessionDOBase< · 1214 unmodified lines
12151215 }
12161216 if (request.method === "GET") {
12171217 const lastEventId = request.headers.get("last-event-id");
1218− if (lastEventId) {
1218+ const resumeHasPendingEvents =
1219+ lastEventId !== null && (await this.eventStore.hasEventsAfter(lastEventId));
1220+ if (lastEventId && resumeHasPendingEvents) {
12191221 await this.supersedeReplayStream(transport, lastEventId);
12201222 } else {
1223+ // Standalone listener path — taken for a bare GET AND for a GET
1224+ // whose Last-Event-ID stream is fully drained. EventSource clients
1225+ // echo their last id on every reconnect, so a drained id must not
1226+ // enter the transport's resume (it closes a completed stream's
1227+ // resume immediately, turning the echo into a reconnect loop).
1228+ let serveRequest = request;
1229+ if (lastEventId) {
1230+ const headers = new Headers(request.headers);
1231+ headers.delete("last-event-id");
1232+ serveRequest = new Request(request, { headers });
1233+ }
12211234 // Latest-listener-wins. Client cancellation is not reliably relayed
12221235 // through every workerd/Vite streaming hop, so explicitly retire a
12231236 // stale standalone mapping before opening its replacement.
12241237 transport.closeStandaloneSSEStream();
12251238 const replay = await this.collectUndeliveredReplay();
1226− if (replay) {
1227− // Prepend the replay onto the transport's own long-lived
1228− // standalone stream — the stream MUST stay open afterwards (see
1229− // collectUndeliveredReplay). Acknowledgement moves to stream end:
1230− // "complete"/"rotate" imply the client stayed attached long
1231− // enough to have drained the prepended frames.
1232− const response = await transport.handleRequest(request);
1233− return this.trackedLegacyResponse(response, {
1234− initialFrame: replay.frame,
1235− acknowledge: replay.streamIds,
1236− });
1237− }
1239+ // Replayed responses ride as the opening frames of the transport's
1240+ // own long-lived standalone stream — the stream MUST stay open
1241+ // afterwards (see collectUndeliveredReplay). Acknowledgement moves
1242+ // to stream end: "complete"/"rotate" imply the client stayed
1243+ // attached long enough to have drained the prepended frames.
1244+ const response = await transport.handleRequest(serveRequest);
1245+ return this.trackedLegacyResponse(
1246+ response,
1247+ replay ? { initialFrame: replay.frame, acknowledge: replay.streamIds } : {},
1248+ );
12381249 }
12391250 }
12401251 const toolCallIds = legacyToolCallRequestIds(parsedBody);
packages/hosts/cloudflare/src/mcp/do-event-store.ts+29−0
@@ -245,6 +245,35 @@ export class DurableObjectMcpEventStore implements EventStore { · 244 unmodified lines
245245 }
246246
247247 /** Replay persisted events after the supplied ID, in storage-key order. */
248+ /**
249+ * Whether the stream named by `lastEventId` still has stored events after
250+ * it — i.e. whether a reconnect GET carrying this id has anything left to
251+ * resume. EventSource clients echo the last id they saw on EVERY
252+ * reconnect, so a drained id must route the connection to the standalone
253+ * listener path instead of a resume (the transport closes a completed
254+ * stream's resume immediately, which turns the echo into a reconnect
255+ * loop).
256+ */
257+ async hasEventsAfter(lastEventId: EventId): Promise<boolean> {
258+ const streamId = streamIdFromEventId(lastEventId);
259+ if (!streamId) return false;
260+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- storage boundary: on probe failure fall back to resume semantics
261+ try {
262+ const rows = await this.storage.list<JSONRPCMessage>({
263+ prefix: eventPrefix(streamId),
264+ startAfter: `${EVENT_KEY_PREFIX}${lastEventId}`,
265+ limit: 1,
266+ });
267+ return rows.size > 0;
268+ } catch {
269+ logStoreWarning("mcp_event_store_list_failed", {
270+ operation: "has_events_after",
271+ streamId,
272+ });
273+ return true;
274+ }
275+ }
276+
248277 async replayEventsAfter(
249278 lastEventId: EventId,
250279 { send }: { readonly send: (eventId: EventId, message: JSONRPCMessage) => Promise<void> },