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
1215 }1215 }
1216 if (request.method === "GET") {1216 if (request.method === "GET") {
1217 const lastEventId = request.headers.get("last-event-id");1217 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) {
1219 await this.supersedeReplayStream(transport, lastEventId);1221 await this.supersedeReplayStream(transport, lastEventId);
1220 } else {1222 } 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 }
1221 // Latest-listener-wins. Client cancellation is not reliably relayed1234 // Latest-listener-wins. Client cancellation is not reliably relayed
1222 // through every workerd/Vite streaming hop, so explicitly retire a1235 // through every workerd/Vite streaming hop, so explicitly retire a
1223 // stale standalone mapping before opening its replacement.1236 // stale standalone mapping before opening its replacement.
1224 transport.closeStandaloneSSEStream();1237 transport.closeStandaloneSSEStream();
1225 const replay = await this.collectUndeliveredReplay();1238 const replay = await this.collectUndeliveredReplay();
1226 if (replay) {1239 // Replayed responses ride as the opening frames of the transport's
1227 // Prepend the replay onto the transport's own long-lived1240 // own long-lived standalone stream — the stream MUST stay open
1228 // standalone stream — the stream MUST stay open afterwards (see1241 // afterwards (see collectUndeliveredReplay). Acknowledgement moves
1229 // collectUndeliveredReplay). Acknowledgement moves to stream end:1242 // to stream end: "complete"/"rotate" imply the client stayed
1230 // "complete"/"rotate" imply the client stayed attached long1243 // attached long enough to have drained the prepended frames.
1231 // enough to have drained the prepended frames.1244 const response = await transport.handleRequest(serveRequest);
1232 const response = await transport.handleRequest(request);1245 return this.trackedLegacyResponse(
1233 return this.trackedLegacyResponse(response, {1246 response,
1234 initialFrame: replay.frame,1247 replay ? { initialFrame: replay.frame, acknowledge: replay.streamIds } : {},
1235 acknowledge: replay.streamIds,1248 );
1236 });
1237 }
1238 }1249 }
1239 }1250 }
1240 const toolCallIds = legacyToolCallRequestIds(parsedBody);1251 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
245 }245 }
246 246
247 /** Replay persisted events after the supplied ID, in storage-key order. */247 /** 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
248 async replayEventsAfter(277 async replayEventsAfter(
249 lastEventId: EventId,278 lastEventId: EventId,
250 { send }: { readonly send: (eventId: EventId, message: JSONRPCMessage) => Promise<void> },279 { send }: { readonly send: (eventId: EventId, message: JSONRPCMessage) => Promise<void> },