Skip to content

fix(mcp): replay every event after POST stream interruption (#1583 follow-up)#1607

Merged
threepointone merged 23 commits into
cloudflare:mainfrom
mattzcarey:fix/mcp-1583-followup
Jun 1, 2026
Merged

fix(mcp): replay every event after POST stream interruption (#1583 follow-up)#1607
threepointone merged 23 commits into
cloudflare:mainfrom
mattzcarey:fix/mcp-1583-followup

Conversation

@mattzcarey

@mattzcarey mattzcarey commented May 28, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #1602. Fixes the correctness bugs on the streamable-HTTP resumability path and replaces the cleanup strategy with immediate clear-on-close — no background sweep, no alarms, quiescent when idle.

Fixes

Final tool response is now replayable

The previous code stored the final response in the event store and then immediately called clearStream(streamId) on shouldClose, deleting every event for that stream — including the one just written. A client that lost the connection mid-flight could reconnect with Last-Event-ID and find nothing to replay.

Fixed by ordering: write the SSE frame to the wire first, then clear. Every event up to and including the final response stays replayable while the in-flight stream is open. Trade-off: if the WS message is enqueued but the client TCP dies before the bytes arrive, that one final message is lost — accepted in exchange for not running any background cleanup.

Request-bound send() writes are unconditional

The standalone branch already stored events regardless of whether a live connection was attached. The request-bound branch only stored when a live connection existed. Long-running tool calls on flaky networks routinely lose the POST connection mid-stream, and those messages were silently disappearing.

McpAgent.getStreamForRequestId() does a reverse lookup against __mcp_stream_reqs__:*, returning both the streamId and the stream's requestIds in one pass, so the transport can resolve a stream even after the originating WS has died. The event still gets stored; only the wire write is skipped when there's no live connection.

Resumed GET registers under the source streamId, and supersedes stale connections

When a client reconnects with Last-Event-ID:

  • Active POST: persisted requestIds are restored so future tool messages route to the new WS.
  • Standalone listen stream: connection takes over that role under STANDALONE_STREAM_ID.
  • Completed POST or unknown: one-shot replay channel.

In every resumable case (and when a fresh standalone GET opens) any prior connection bound to the same streamId is closed, so there's at most one live connection per stream. This kills the routing race where send() could pick a stale POST bridge over the resumed GET, and keeps us within the MCP rule of one stream per message.

Spec: one stream per message

MCP 2025-06-18 "Multiple Connections": the server MUST send each JSON-RPC message on only one connected stream and MUST NOT broadcast across streams. Server-initiated notifications go to the single standalone GET; POST responses go to their own stream. Combined with the supersede-on-resume behaviour above, there is never more than one standalone GET to choose from.

Cleanup architecture

Immediate, not background. Each POST stream's events are cleared the moment the close frame is written — no alarms, no metadata index, no sweep. Idle DOs do zero periodic work.

Storage cost is bounded by the in-flight POST streams plus the standalone GET stream. Defensive bounds:

  • clearStream lists + deletes in chunks of 128 (the DO multi-key delete cap), so a large stream never loads the whole event log into memory.
  • replayEventsAfter caps each call at 1000 events (start: <lastEventId>\x00 for strict-after, exclusive replay).
  • getStreamForRequestId caps its scan at 1000 keys and warns if hit (signals leaked __mcp_stream_reqs__ entries).
  • storeEvent rejects streamIds containing : so custom embedder ids can't cause prefix-scan collisions.

Standalone GET events are not cleared automatically; they accumulate for the lifetime of the session's Durable Object (bounded by session length).

Exports

DurableObjectEventStore and the ClearableEventStore interface are exported from agents/mcp so callers embedding WorkerTransport inside an Agent or Durable Object can opt into resumability with new DurableObjectEventStore(this.ctx.storage).

Verified against the MCP spec + SDK reference

  • One stream per message; no broadcast (spec "Multiple Connections"). ✅
  • Event ids globally unique across streams in a session. ✅
  • Replay is per-stream and exclusive of Last-Event-ID; never cross-stream. ✅
  • Request-bound storage outlives WS drops via persistent reverse lookup (mirrors SDK _requestToStreamMapping). ✅
  • Resumed connection maps under source streamId (matches SDK replayEvents). ✅

Testing

  • 462/462 MCP tests pass. Added coverage for: mid-flight POST disconnect + GET resume, standalone single-send + no-live store-only, supersede-on-resume (POST + standalone), 128-key chunked delete, 1000-event replay cap, global event-id uniqueness, streamId : rejection. Two behaviours mutation-checked (supersede close, id uniqueness).

Compatibility

Patch-level, additive:

  • No public API changes on existing types.
  • New: DurableObjectEventStore + ClearableEventStore exported.
  • McpAgent.getStreamForRequestId() is @internal.
  • Behaviour changes only widen the set of events that survive disconnects and remove idle DO billing.

@changeset-bot

changeset-bot Bot commented May 28, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 22b9e0f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
agents Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 5 additional findings.

Open in Devin Review

@mattzcarey
mattzcarey force-pushed the fix/mcp-1583-followup branch from fff89fd to 943188d Compare May 28, 2026 14:00
@pkg-pr-new

pkg-pr-new Bot commented May 28, 2026

Copy link
Copy Markdown

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@1607

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@1607

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@1607

hono-agents

npm i https://pkg.pr.new/hono-agents@1607

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@1607

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@1607

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@1607

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@1607

commit: 22b9e0f

…e#1583 followup)

Reworking the resumability cleanup after a series of review rounds.
Net result: -233 lines from the previous followup, no functional
regression on the actual fixes.

The Important Fixes (unchanged from earlier rounds)

1. Drop clearStream-on-shouldClose. Final tool response stays in the
   event store so a client that lost the connection right as it was
   in flight can reconnect with Last-Event-ID and recover it.

2. Unconditional storage on the request-bound send() path, matching
   the SDK. Falls back to McpAgent.getStreamIdForRequestId (a reverse
   lookup against __mcp_stream_reqs__:*) when the originating WS has
   dropped, so events are still recorded for replay.

3. Resumed GET registers under the source streamId, matching the SDK
   exactly. Active POST resumption inherits requestIds; standalone
   resumption takes over the listen role; completed POST resumption
   is a one-shot replay channel.

The Storage Bounding (simplified)

Earlier rounds layered cap-based eviction + a complex per-event-or-
per-stream timestamped sweep + a recurring cron. After working
through it:

- Per-stream cap removed. Real streams never approach 256 events, and
  truncating replay history silently was arguably worse than letting
  the stream grow. DO storage is bounded structurally anyway.

- Sweep is alarm-driven, not cron. storeEvent fires onStoreEvent;
  McpAgent uses it to idempotent-schedule a single cleanup at now +
  maxAgeMs. The callback sweeps streams whose lastWriteAt is past
  cutoff, then either reschedules at the next-earliest expiry or
  doesn't reschedule at all. Quiescent DOs do no periodic work.

- Sweep cost is O(active streams), not O(total events): a per-stream
  metadata key (__mcp_stream_evt_meta__) tracks lastWriteAt, the
  sweep scans only that index and bulk-deletes events for streams
  whose meta is expired.

- Stream-reqs cleanup folded into the same sweep. When sweep deletes
  a stream, the agent also deletes the matching __mcp_stream_reqs__
  entry. No separate sweepStreamReqs, no timestamp on reqs entries.

- All the knobs removed: getEventStoreSweepCron,
  getEventStoreSweepMaxIterations, batchSize, the cursor-pagination
  question. Just getEventStoreMaxAgeMs (default 24h).

DurableObjectEventStore is exported so callers embedding
WorkerTransport inside an Agent (e.g. the elicitation example) can
opt into resumability with new DurableObjectEventStore(this.ctx.storage).

Tests: 455/455 MCP tests pass. npm run check exit 0 across all 88
projects. event-store.ts is now 198 lines (down from 323); McpAgent
additions for cleanup are ~50 lines instead of ~200.
@mattzcarey
mattzcarey force-pushed the fix/mcp-1583-followup branch from 943188d to 10844dc Compare May 28, 2026 14:38
@threepointone

Copy link
Copy Markdown
Contributor

clanker review, some of these look legit:

High Severity

Stale POST WebSockets can win routing after GET resume

StreamableHTTPServerTransport.handleGetRequest() restores requestIds onto a
new GET connection when resuming an active POST stream, but it does not clear or
close the original POST connection. If the HTTP SSE client disconnected but the
Durable Object WebSocket has not been torn down, both connections can carry the
same requestIds.

send() picks the first live connection whose state contains the request id:

const liveConnection = Array.from(
  agent.getConnections<TransportConnState>()
).find((conn) => conn.state?.requestIds?.includes(requestId as RequestId));

If the stale POST bridge is iterated first, subsequent tool progress or final
results are written to the stale connection instead of the resumed GET stream.
Events are still stored for replay, but live delivery after resume becomes
nondeterministic.

Suggested fix: when a GET resumes a stream, supersede prior connections for that
streamId/requestIds by clearing their requestIds state or closing them.
Also consider propagating HTTP response abort/cancel back to the DO WebSocket in
the streamable HTTP handler.

Bulk deletes can exceed Durable Object storage limits

DurableObjectEventStore.clearStream() and sweep() gather all event keys and
call storage.delete(keys) once. Durable Object storage bulk delete is limited
to 128 keys per call.

A stream with more than 128 events, or a cleanup sweep covering many streams,
can fail in production even though the mock storage accepts any array size.

Suggested fix: delete in chunks of at most 128 keys, and add tests with storage
mocks that enforce this limit.

Sweep can delete a stream that receives a concurrent write

sweep() scans stream metadata, marks expired streams, then awaits further
storage operations before deleting those streams. A storeEvent() can occur
during those awaits, update lastWriteAt, and write a new event. The sweep still
deletes the stream because it was expired in the earlier scan.

Suggested fix: before deleting a stream, re-read its metadata or use another
guard to confirm lastWriteAt is still older than the cutoff.

Medium Severity

Replay and cleanup are not paginated

replayEventsAfter(), clearStream(), and sweep() each perform single
storage.list() calls. This creates two risks:

  • Long missed histories may be truncated if list results are capped, or may load
    too much into memory if unbounded.
  • Cleanup may miss metadata or event rows beyond the first page, and deleting
    metadata after only partially deleting events can leave orphaned event keys
    that future sweeps will never see.

Suggested fix: use explicit limits and pagination loops for replay, per-stream
deletion, and metadata sweeps.

Prefix scanning can collide if stream ids contain colons

Event keys are stored as:

__mcp_event__:<streamId>:<seqHex>

Operations build prefixes with __mcp_event__:${streamId}:. If stream ids such
as a and a:b both exist, scanning or clearing stream a also matches keys
for a:b.

Default stream ids appear safe, but exported DurableObjectEventStore can be
used by embedders with custom stream ids.

Suggested fix: encode streamId in storage keys, or document/enforce that
stream ids must not contain :.

Completed-POST resume test is mislabeled as standalone resume

resumability.test.ts has a test named "resumed GET stream is registered as the
standalone SSE stream", but it resumes from a POST tool-call event id. After the
POST completes, deleteStreamRequestIds() has already run, so this is a
completed-POST replay channel, not a standalone stream. The test only asserts
HTTP 200 and does not verify standalone registration or future live delivery.

Suggested fix: add a true standalone test: open standalone GET, receive a
standalone event id, disconnect, reconnect with Last-Event-ID, trigger a
server-initiated notification, and assert it arrives live.

No test covers true mid-flight POST disconnect and live continuation

The POST replay test reads the final result, cancels the response, then resumes
from the prior id. This validates replay of a completed stream, not the main
failure mode where the POST SSE stream is interrupted while the tool call is
still running and the resumed GET should receive later messages live.

Suggested fix: add an end-to-end test with a deferred tool: start POST, read an
initial event id, cancel/abort the POST response before releasing the tool,
resume with GET + Last-Event-ID, release the tool, and assert the final result
arrives on the resumed GET stream.

Lower Severity

getStreamIdForEventId() accepts synthetic event ids

getStreamIdForEventId() derives the stream id by splitting on the last colon
and does not verify that the event exists. A client can send a fake
Last-Event-ID, causing the transport to register a ghost replay connection and
replay nothing.

Suggested fix: verify the event key exists before treating the id as resumable,
or fall through to a fresh standalone stream when no matching event is found.

_requestResponseMap is in-memory for batched POSTs

For batched requests, shouldClose depends on _requestResponseMap. If the DO
hibernates between responses, that in-memory map can be lost, leaving the stream
mapping uncleared and the close frame unsent. The MCP spec discourages batched
requests, so this is lower risk, but the transport still accepts them.

Multiple standalone GET streams have last-writer-wins behavior

The standalone send path iterates all _standaloneSse connections and keeps the
last one. Earlier standalone streams silently stop receiving live
server-initiated messages, even though the spec allows multiple concurrent SSE
streams. Stored events still make replay possible.

Comments are stale or misleading

  • transport.ts says cleanup is "scheduled hourly from McpAgent.onStart",
    but the new cleanup is armed by storeEvent() and scheduled around
    maxAgeMs.
  • index.ts mentions maxEventsPerStream, but the PR removed that cap.
  • replayEventsAfter() comments about list defaults should be verified against
    current Durable Object storage semantics.

Coverage Gaps To Add

  • Bulk delete over 128 keys.
  • Replay with more than one list page of events.
  • Sweep with more than one list page of metadata.
  • Sweep of a stream with more than one list page of events.
  • Sweep racing with a fresh storeEvent().
  • Prefix-collision stream ids such as a and a:b.
  • Malformed or synthetic Last-Event-ID.
  • Active POST disconnect, GET resume, and live final-result delivery.
  • Standalone GET disconnect, GET resume, and live notification delivery.

@mattzcarey

Copy link
Copy Markdown
Contributor Author

actually gonna drop the store sweep and just clean up on successful stream. only DOs with un finished streams will have events left and I think thats fine. this is way too much complexity since I am gonna rip it all out in a month anyway.

Matt Carey and others added 2 commits May 29, 2026 18:45
Rip the alarm-driven sweep added in the previous push of this PR.
Storage cost is now bounded by in-flight POST streams (cleared the
moment their close frame is written) plus the standalone GET stream.

- event-store: drop sweep(), drop the __mcp_stream_evt_meta__ index,
  drop the onStoreEvent option. Assert streamId contains no ':' to
  rule out prefix-scan collisions. Chunk multi-key deletes at the DO
  128-key cap. Cap replayEventsAfter at 1000 events to bound memory.
- mcp/index: drop _cf_armEventStoreCleanup,
  _cf_runEventStoreCleanup, and getEventStoreMaxAgeMs. getEventStore()
  is now plain 'new DurableObjectEventStore(this.ctx.storage)'.
- transport: writeSSEEvent FIRST, then deleteStreamRequestIds +
  clearStream. Trade-off: if the WS pipe is enqueued but the client
  TCP dies before the bytes arrive, that one final message is lost.
  Every earlier event is still replayable while the stream is open.
- transport: when a GET resumes an active POST stream, strip
  requestIds off any other connection still claiming them, so stale
  POST bridges can't win the routing race against the resumed GET.
- transport: fan standalone notifications out to every _standaloneSse
  connection instead of last-writer-wins. Matches the spec.
- tests: replace the completed-POST 'replay' test with a real
  mid-flight POST disconnect test using a new deferredGreet tool that
  emits a progress notification then sleeps. Drop the misnamed
  'resumed GET is registered as standalone' test (it was actually a
  POST-id resume). Drop event-store sweep tests; add chunked-delete
  and replay-cap tests.
@mattzcarey

Copy link
Copy Markdown
Contributor Author

/bonk review this pls

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid PR overall — this is a meaningful correctness improvement to the resumability path. The decision to drop the alarm-based sweep in favor of immediate cleanup on shouldClose is the right call, and the changeset is well-written. A few things I'd flag before merging:

Key issues

  1. clearStream doesn't paginate its list() call — While deletes are correctly chunked at 128, the storage.list({ prefix }) call at the top of clearStream has no limit and loads every key for the stream into memory in one shot. For typical streams (a handful of progress events + a result) this is fine, but the class is now exported for embedders. Consider either paginating the list or at least documenting the memory ceiling.

  2. getStreamIdForRequestId is a full scan — This scans every __mcp_stream_reqs__:* key on every send() when the live connection has dropped. During a burst of tool progress messages on a flaky connection this could be hot. It's acceptable for now since the number of in-flight POST streams per DO is small, but worth a // TODO or a note in the docstring.

  3. clearStream races with replayEventsAfter — After writeSSEEvent in send(), the code awaits deleteStreamRequestIds and then clearStream. Meanwhile, a concurrent GET reconnect could be mid-replayEventsAfter on the same stream. The list() in clearStream would then delete events that replayEventsAfter is still iterating. In practice the replay send callback writes to a WS synchronously, so the window is tiny, but it's worth a comment acknowledging the race.

  4. Standalone GET events are never cleaned up — POST stream events get clearStream'd on close, but STANDALONE_STREAM_ID events accumulate for the lifetime of the DO. The doc says "the DO itself dies with the session" which is true, but for long-lived sessions with chatty server-initiated notifications this could grow. Fine for v1, but should be called out as a known limitation.

See inline comments for specifics.

Comment thread packages/agents/src/mcp/event-store.ts Outdated
Comment thread packages/agents/src/mcp/index.ts
Comment thread packages/agents/src/mcp/transport.ts Outdated
Comment thread packages/agents/src/mcp/transport.ts Outdated
Comment thread packages/agents/src/mcp/transport.ts Outdated
Comment thread packages/agents/src/tests/mcp/resumability.test.ts
Comment thread packages/agents/src/mcp/event-store.ts
Comment thread packages/agents/src/mcp/transport.ts Outdated
@ask-bonk

ask-bonk Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

Review submitted. Here's a summary of what I flagged:

Top-level findings:

  1. clearStream doesn't paginate its list() call — Deletes are correctly chunked at 128, but the initial storage.list({ prefix }) has no limit and loads all keys into memory. Since DurableObjectEventStore is now exported for embedders, this could be a problem for unbounded streams. Suggested a paginated list+delete loop that would also eliminate the separate deleteChunked helper.

  2. getStreamIdForRequestId is an O(n) full scan of all __mcp_stream_reqs__:* keys, called on every send() when the live connection has dropped. Acceptable for now (few in-flight POST streams per DO), but worth noting.

  3. clearStream can race with replayEventsAfter — After the wire write, deleteStreamRequestIds + clearStream could overlap with a concurrent GET reconnect that's mid-replay on the same stream. The window is tiny but worth a comment.

  4. Standalone GET events are never cleaned upSTANDALONE_STREAM_ID events accumulate for the DO's lifetime. Fine for v1 but should be documented as a known limitation.

Inline comments on specific lines covering: the unpaginated list in clearStream, the full-scan cost of the reverse lookup, the ordering dependency between deleteStreamRequestIds/clearStream, a misleading error message, a double storage read in the disconnected-client path, the stale-connection supersession fix (positive), and a note on the mid-flight test's timing sensitivity.

github run

Four structural cleanups, no behaviour change. All 453 mcp tests
still pass.

- Add ClearableEventStore interface (extends EventStore with
  clearStream). Replaces an inline cast + optional chaining dance
  with a real named contract and proper 'in' narrowing.
- Supersede stale POST bridges by closing them (conn.close(1000,
  'Superseded by resumed stream')) instead of reaching in and
  mutating their state. Matches the SDK's last-writer-wins
  _streamMapping mental model. Removes a 'spooky action at a
  distance' pattern.
- Split send() into sendStandalone() and sendForRequest(). The two
  paths share nothing but the storeEvent call and were fused for no
  good reason. Removes the disjoint shouldClose-cleanup-after-write
  block as a side effect.
- Drop the now-unused 'eventStore' getter on the transport. Its only
  caller was _cf_runEventStoreCleanup which was deleted in the
  previous commit.
@mattzcarey

Copy link
Copy Markdown
Contributor Author

/bonk look again pls.

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second pass review. The shift from alarm-based sweep to immediate cleanup on shouldClose is a good simplification — much less surface area, no billing for quiescent DOs, and the trade-off (final message lost if TCP dies after WS enqueue) is clearly documented. The sendStandalone/sendForRequest split and the stale-connection supersession fix are both solid.

A few remaining items, mostly around edge cases in clearStream and the reverse-lookup fallback path. Nothing blocking, but worth addressing before merge.

Comment thread packages/agents/src/mcp/event-store.ts Outdated
async clearStream(streamId: StreamId): Promise<void> {
const prefix = `${DurableObjectEventStore.KEY_PREFIX}${streamId}:`;
const prefix = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${streamId}:`;
const rows = await this.storage.list({ prefix });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous review flagged this and it still applies: storage.list({ prefix }) with no limit loads every key for the stream into memory before chunking the deletes. The delete side is correctly chunked at 128, but the list isn't bounded. For typical streams this is fine, but since DurableObjectEventStore is now exported for embedders, a pathological stream (or a standalone stream that accumulates notifications) could pull a lot of keys.

You could fold the list into the same chunked loop:

Suggested change
const rows = await this.storage.list({ prefix });
const prefix = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${streamId}:`;
let cursor: string | undefined;
for (;;) {
const rows = await this.storage.list({
prefix,
...(cursor ? { start: cursor } : {}),
limit: DurableObjectEventStore.DELETE_CHUNK
});
if (rows.size === 0) break;
const keys = [...rows.keys()];
await this.storage.delete(keys);
if (rows.size < DurableObjectEventStore.DELETE_CHUNK) break;
cursor = keys[keys.length - 1] + "\x00";
}

This eliminates deleteChunked entirely and caps memory at 128 keys.

Comment thread packages/agents/src/mcp/transport.ts Outdated
const streamId =
liveConnection?.state?.streamId ??
(await agent.getStreamIdForRequestId(requestId));
if (!streamId) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previous review noted the error message is misleading — when you land here it's because the stream-reqs mapping is gone (deleted by a concurrent shouldClose or never existed), not because there's no connection. Consider:

Suggested change
if (!streamId) {
if (!streamId) {
throw new Error(
`No active stream found for request ID: ${String(requestId)}`
);
}

Comment thread packages/agents/src/mcp/transport.ts Outdated
this._requestResponseMap.set(requestId, message);
const relatedIds = connection.state?.requestIds ?? [];
// Check if we have responses for all requests using this connection
const relatedIds =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When liveConnection is null (client dropped mid-tool-call), this falls through to agent.getStreamRequestIds(streamId) — which hits storage for the same __mcp_stream_reqs__:* data that getStreamIdForRequestId just scanned on line 483. Two storage reads for the disconnected-client path on every send(). Not a blocker since the set of in-flight POST streams is small, but worth a note.

One way to collapse it: have getStreamIdForRequestId return both the streamId and the requestIds in one pass, or cache the scan result for the duration of this sendForRequest call.

Comment thread packages/agents/src/mcp/transport.ts Outdated
Comment on lines 511 to 515
if (shouldClose) {
await agent.deleteStreamRequestIds(streamId);
if (this._eventStore && "clearStream" in this._eventStore) {
await this._eventStore.clearStream(streamId);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After writeSSEEvent on line 508, the await deleteStreamRequestIds + await clearStream sequence is correct, but there's a subtle ordering window: a concurrent GET resume arriving between lines 512 and 514 would find no persisted requestIds (deleted), treat the stream as a completed-POST one-shot replay, and start iterating events that clearStream is about to delete. The replay send callback writes synchronously to the WS, so in practice the race is benign — but a brief comment here would save future readers from having to reason through it.

Also: "clearStream" in this._eventStore is a duck-type check. Since you've defined the ClearableEventStore interface, you could use a type guard or just check at construction time and store a narrowed reference. Minor style nit.

async getStreamIdForRequestId(
requestId: RequestId
): Promise<string | undefined> {
const rows = await this.ctx.storage.list<RequestId[]>({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is O(n) over all __mcp_stream_reqs__:* keys, called on every send() when the live connection has dropped. For the normal case (few in-flight POST streams per DO) this is fine, but it's worth a // O(n) in active POST streams comment so future readers know the cost.

Also, the list() call has no limit — a defensive cap (say 1000) would prevent a theoretical unbounded memory load if stream-reqs mappings accumulate from abandoned POSTs where deleteStreamRequestIds was never called.

params: { name: "greet", arguments: { name: "resume" } }
params: {
name: "deferredGreet",
arguments: { name: "midflight", delayMs: 600 }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the key test for the PR and it's well-structured. One thing to consider: the delayMs: 600 makes this timing-dependent. If the test runner is slow and the tool completes before reader.cancel() on line 266, the POST stream will have already delivered the result and shouldClose will have fired (deleting the stream events), so the resumed GET will have nothing to replay.

The bounded loop on line 252 helps, but you might want an assertion that the POST was actually cancelled before the result arrived — e.g., assert that postBuf does not contain "result" at the point of cancel. That way a timing regression turns into a clear test failure rather than a false pass.

Comment thread packages/agents/src/mcp/transport.ts Outdated
return;
for (const conn of agent.getConnections<TransportConnState>()) {
if (!conn.state?._standaloneSse) continue;
this.writeSSEEvent(conn, message, eventId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fix — fanning out to all _standaloneSse connections rather than last-writer-wins matches the spec's allowance for multiple concurrent SSE streams. Note that writeSSEEvent can throw (the WS might have been torn down), and this loop doesn't catch per-connection errors — a dead connection would abort delivery to all subsequent connections in the iteration. Consider wrapping each writeSSEEvent call in a try/catch so one dead WS doesn't block the rest:

Suggested change
this.writeSSEEvent(conn, message, eventId);
for (const conn of agent.getConnections<TransportConnState>()) {
if (!conn.state?._standaloneSse) continue;
try {
this.writeSSEEvent(conn, message, eventId);
} catch (error) {
this.onerror?.(error as Error);
}
}

Comment thread packages/agents/src/mcp/transport.ts Outdated
for (const other of agent.getConnections<TransportConnState>()) {
if (other.id === connection.id) continue;
if (other.state?.streamId !== resumedStreamId) continue;
other.close(1000, "Superseded by resumed stream");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good addition — this directly addresses the stale-connection routing race from the earlier review. close(1000, ...) is the right call since the old WS is dead anyway.

One thing: this fires for every connection whose streamId matches, which is correct for the POST-resume case. But if a client somehow opens two GET resumes for the same POST stream in quick succession, the second resume would close the first resumed GET. That's arguably correct (last-writer-wins on resume), but it's worth a comment noting the semantics.

@ask-bonk

ask-bonk Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

Review submitted. Here's a summary of what I flagged on the second pass:

Overall: The PR is in good shape. Dropping the alarm-based sweep for immediate cleanup on shouldClose is the right call — much simpler, no idle DO billing, and the trade-off is well-documented. The sendStandalone/sendForRequest split, stale-connection supersession, and fan-out to all standalone GETs are all solid improvements.

Inline comments (8 total):

  1. clearStream list isn't paginated (event-store.ts:132) — Deletes are chunked at 128 but the initial list() loads all keys. Suggested a paginated list+delete loop that eliminates deleteChunked entirely.

  2. Misleading error message (transport.ts:484) — "No connection established" should be "No active stream found" since the issue is a missing stream mapping, not a missing connection.

  3. Double storage read on disconnected-client path (transport.ts:497) — getStreamIdForRequestId scans __mcp_stream_reqs__:*, then getStreamRequestIds hits the same data. Suggested returning both in one pass.

  4. clearStream race with replay + duck-type check (transport.ts:511-515) — Window between deleteStreamRequestIds and clearStream where a concurrent GET resume could see deleted events. Benign but deserves a comment. Also, "clearStream" in this._eventStore could use the ClearableEventStore type guard instead.

  5. getStreamIdForRequestId O(n) scan (index.ts:115) — Worth a cost comment and a defensive limit on the list() call.

  6. Mid-flight test timing sensitivity (resumability.test.ts:237) — Suggested asserting that postBuf does NOT contain "result" at the point of cancel, so a timing regression becomes a clear failure rather than a false pass.

  7. Standalone fan-out error handling (transport.ts:448) — One dead WS in the loop could abort delivery to all subsequent connections. Suggested per-connection try/catch.

  8. Stale connection close semantics (transport.ts:233) — Good fix, but worth noting that two rapid GET resumes for the same stream would cause the second to close the first.

github run

Matt Carey added 2 commits May 29, 2026 18:55
Three legit findings, two no-ops:

- clearStream: replace 'list-all-then-chunk-delete' with a paginated
  list+delete loop bounded by DELETE_CHUNK. Removes the deleteChunked
  helper as a side effect \u2014 the chunk size is now enforced by the
  list limit itself, not a separate pass. Net simpler.
- Clearer error message in sendForRequest: 'No active stream found'
  instead of 'No connection established', since the failure is a
  missing stream mapping, not a missing connection.
- resumability test: assert the progress notification is NOT
  re-delivered on the resumed GET. The Last-Event-ID *was* the
  progress event, so replayEventsAfter must skip it.

Not acting on:
- O(n) full scan in getStreamIdForRequestId: working set is in-flight
  POST streams per DO, which is small.
- clearStream/replayEventsAfter race: window is between two awaits in
  the same microtask chain and the consequence (replaying about-to-be-
  deleted events) is harmless.
- Double storage read in disconnected-client path: only on the slow
  path; threading data through would complicate the API.
- Standalone GET events accumulate: already documented; bounded by
  session lifetime by design.
Five small wins from the second-pass review:

- Replace the 'clearStream' in this._eventStore duck-type check with
  a proper isClearableEventStore type guard. Same runtime check,
  better readability, narrows the type through the inferred call.
- Add an ordering note on the deleteStreamRequestIds -> clearStream
  sequence: the tiny window where a concurrent GET resume sees no
  requestIds and starts replaying soon-to-be-deleted events is
  benign, but the comment saves future readers from re-deriving it.
- getStreamIdForRequestId: add an O(n) cost comment and a defensive
  limit: 1000 on the list call so an abandoned-POST leak can't
  unbounded-load the scan.
- Standalone fan-out: wrap each writeSSEEvent in try/catch so one
  dead WS in the middle of the loop can't block delivery to the
  remaining standalone connections.
- Mid-flight resumability test: assert postBuf does NOT contain
  '"result"' at the point of cancel, so a timing regression turns
  into a clear failure rather than a false pass.
- Comment on the supersede-by-close loop noting the last-writer-wins
  semantic for rapid back-to-back GET resumes on the same stream.

Skipped:
- Collapsing the double storage read on the disconnected path
  (getStreamIdForRequestId + getStreamRequestIds): would require
  widening the agent API to return both in one pass. Slow-path-only,
  working set is single-digit streams. Not worth the API change.
@mattzcarey

Copy link
Copy Markdown
Contributor Author

/bonk look again

@ask-bonk

ask-bonk Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

UnknownError: ProviderInitError

github run

@ask-bonk

ask-bonk Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

@mattzcarey Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.

@threepointone

Copy link
Copy Markdown
Contributor

/bonk look again

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Third-pass review. The PR is in solid shape after incorporating feedback from the first two rounds. The shift from alarm-based sweep to immediate cleanup on shouldClose is the right call, the clearStream pagination is correct, the stale-connection supersession logic is clean, the standalone fan-out try/catch is in place, and the mid-flight test has the "result" guard. A few remaining items below, mostly around the double storage read on the disconnected path and a type export gap.

Comment thread packages/agents/src/mcp/transport.ts Outdated
Comment thread packages/agents/src/mcp/transport.ts
Comment thread packages/agents/src/mcp/event-store.ts
Comment thread packages/agents/src/mcp/transport.ts Outdated
Comment thread packages/agents/src/tests/mcp/resumability.test.ts
Comment thread packages/agents/src/tests/mcp/event-store.test.ts Outdated
Comment thread packages/agents/src/mcp/index.ts Outdated
@ask-bonk

ask-bonk Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor

Review submitted. Here's a summary of what I flagged on the third pass:

Overall: The PR is in good shape. Most feedback from the first two rounds has been addressed — clearStream pagination, stale-connection supersession, standalone fan-out try/catch, the "result" timing guard in the mid-flight test, and the isClearableEventStore type guard are all solid.

Inline comments (7 total):

  1. Double storage read on disconnected-client path (transport.ts:517) — getStreamIdForRequestId already scanned __mcp_stream_reqs__:*, then getStreamRequestIds hits the same key again. Suggested collapsing into one pass.

  2. ClearableEventStore not re-exported (transport.ts:94) — The interface is exported from transport.ts but not from mcp/index.ts, so embedders who want to implement their own clearable store can't import the type. Suggested adding it to the barrel export.

  3. Replay off-by-one (event-store.ts:111) — REPLAY_LIMIT includes lastKey in the list() result, which is then skipped, so the effective cap is 999 not 1000. Suggested REPLAY_LIMIT + 1 or using start: lastKey + "\x00".

  4. Lost close frame when no live connection (transport.ts:525) — When the client has dropped, writeSSEEvent is skipped so the close signal is never delivered. The behavior is correct (cleanup still runs), but worth a comment noting the trade-off.

  5. Positive note on replay exclusivity assertion (resumability.test.ts:315) — The not.toContain("notifications/progress") assertion is a good catch for accidental inclusive-replay regressions.

  6. Test assertion doesn't tightly verify boundary (event-store.test.ts:275) — <= 1000 passes but actual cap is 999. Suggested tightening to toBe(999).

  7. Silent miss at 1000 key ceiling (index.ts:122) — If abandoned POSTs accumulate beyond 1000 __mcp_stream_reqs__:* keys, the scan silently misses the target and send() throws a confusing error.

github run

Matt Carey and others added 4 commits June 1, 2026 08:42
Six fixes:

- Collapse the double storage read on the disconnected-client send
  path. getStreamIdForRequestId is now getStreamForRequestId and
  returns { streamId, requestIds } in one pass; sendForRequest no
  longer issues a second getStreamRequestIds for the same key.
- Re-export ClearableEventStore from mcp/index.ts so embedders who
  want to implement their own clearable store can import the type.
- Fix the replayEventsAfter off-by-one: use start: <key>+'\x00' so
  the list result strictly excludes lastEventId rather than including
  it and post-filtering. Effective cap is now exactly 1000, not 999.
  Drops the (key <= lastKey) guard as a side effect.
- Tighten the replay-cap test from <=1000 to ==1000 \u2014 catches the
  off-by-one if anyone reverts.
- Comment the no-live-connection branch in sendForRequest: the close
  frame is intentionally dropped when there's nowhere to write it.
  The client's reconnect with Last-Event-ID gets the final event
  (until cleanup runs immediately after) and the spec lets them
  treat it as final.
- Log a warning if getStreamForRequestId hits its 1000-key scan cap.
  Hitting it means abandoned __mcp_stream_reqs__ entries are
  accumulating, which would silently turn into 'No active stream
  found' errors otherwise.
The merge of main into this branch left two fused copies of
sendForRequest \u2014 main's collision-routing version and this PR's
no-live-connection fallback version \u2014 plus dangling references to
the old _requestResponseMap (renamed to _streamResponseIds in main).
The file built but the logic was incoherent.

This commit restructures sendForRequest so both intents coexist
cleanly:

  1. Pick the live connection using main's collision-safe rule:
     prefer the originating connection; fall back to the unique
     matching connection; otherwise null.
  2. If multiple live connections claim the request id and none is
     the originating, route an Internal Error to each \u2014 main's
     cloudflare#1639 protocol-safety behaviour.
  3. If a single live connection owns the request, delegate to
     sendOnStream (main's canonical store + close-detect + cleanup
     path).
  4. Otherwise fall back to this PR's getStreamForRequestId reverse
     lookup so the event is still stored for replay when the
     originating WS has dropped, and run cleanup on shouldClose.
     The close frame is intentionally dropped when there's no live
     connection \u2014 documented trade-off in send()'s docstring.

No behaviour change for the live-connection path (that's main's
sendOnStream verbatim). The dropped-WS replay path now exists again,
which is what this PR was originally about.
The previous commit's repair of the botched merge left sendOnStream
and sendForRequest's no-live-connection fallback as two parallel
implementations of the same logical function. They had already drifted
in this branch (live path used a cast, fallback used the
isClearableEventStore type guard), and a 'void eventId;' silencing
betrayed the fact that the structure wasn't right.

sendOnStream now takes (streamId, relatedIds, liveConnection,
message, requestId) directly. The caller resolves where the message
goes; sendOnStream handles store + close-detect + cleanup + write.
writeSSEEvent is gated on liveConnection being non-null \u2014 same
behaviour, expressed as one branch instead of two.

sendForRequest is now four phases readable top-to-bottom:
  1. find matching live connections
  2. ambiguous-multi \u2192 error route to each
  3. resolve streamId + relatedIds (from connection state, or
     persisted fallback if the WS dropped)
  4. one sendOnStream call

No behaviour change. 457/457 mcp tests still pass. Net -10 lines,
two cleanup paths collapsed to one, type-guard drift fixed, 'void
eventId;' smell removed.
@mattzcarey

Copy link
Copy Markdown
Contributor Author

/bonk do a last review. does it all look good?

ask-bonk[bot]
ask-bonk Bot previously requested changes Jun 1, 2026

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final review

This is a well-structured PR overall — the shift from alarm-based sweep to immediate cleanup, the sendStandalone/sendForRequest split, the stale-connection supersession logic, the standalone fan-out try/catch, the streamId colon guard, and the clearStream chunking are all good improvements. The test coverage for the mid-flight disconnect scenario is exactly the right test to have.

However, there is one critical ordering bug that undermines the main fix claimed by the PR, plus a changeset inaccuracy. Everything else looks good to ship.

Critical: sendOnStream cleanup runs before wire write — contradicts the stated fix

The PR description, the changeset, and the sendOnStream docstring all claim that writeSSEEvent runs last — after cleanup. The changeset explicitly says:

Fixed by flipping the order: write the SSE event to the wire first, then drop the persisted streamId -> requestIds mapping and clear the stored events.

The docstring on sendOnStream says:

Ordering note: writeSSEEvent runs LAST.

But the code does the opposite: deleteStreamRequestIds and clearStream both run before writeSSEEvent. This means the original bug described in the changeset (events deleted from store before the wire write completes) still exists when shouldClose is true. A client whose TCP dies between the clearStream await and the writeSSEEvent call cannot resume with Last-Event-ID because the events are already gone.

To match the stated intent, move the cleanup block after the wire write.

Minor: changeset references wrong method name

The changeset at line 26 says McpAgent.getStreamIdForRequestId but the actual method is McpAgent.getStreamForRequestId.

Comment thread packages/agents/src/mcp/transport.ts Outdated
Comment thread .changeset/mcp-resumability-followups.md Outdated
In the collapse-the-duplicate refactor (6b5990c), the cleanup
sequence in sendOnStream ended up running BEFORE writeSSEEvent \u2014
the exact ordering the docstring and changeset warn against, and the
exact bug Sunil's clanker review and the original cloudflare#1583 ticket are
about.

Failure mode: tool finishes, sendOnStream is called with a still-live
liveConnection (e.g. resumed GET), storeEvent writes the final event,
shouldClose=true triggers clearStream which wipes the entire stream
INCLUDING the event we just stored, then writeSSEEvent emits a frame
with an event id that no longer resolves. A client losing the WS pipe
at that exact moment can reconnect with Last-Event-ID and find
nothing to replay.

Fix: write the SSE frame first, then run cleanup. Matches the
docstring already in place.

Also fixes bonk cloudflare#25: changeset referenced
McpAgent.getStreamIdForRequestId; renamed to getStreamForRequestId
in 880c013.
@cloudflare cloudflare deleted a comment from ask-bonk Bot Jun 1, 2026
Connection state arrays are ImmutableArray<RequestId>; sendOnStream
only reads via .every() so the parameter type can accept readonly
RequestId[]. Caught by CI typecheck.
@mattzcarey

Copy link
Copy Markdown
Contributor Author

/bonk go fo it. rip me to shreds

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

This is a well-structured PR. The core correctness fixes (unconditional event storage, write-before-cleanup ordering, stale-connection supersession, standalone fan-out) are sound and the test coverage for the mid-flight disconnect scenario is the right test to have. The simplification from alarm-based sweep to immediate cleanup on shouldClose is a clear win — less code, no idle DO billing, and the trade-off is well-documented.

Addressing the last bonk review's "critical" finding

The final bonk review flagged a critical ordering bug claiming deleteStreamRequestIds + clearStream run before writeSSEEvent. That's incorrect. Reading sendOnStream at lines 439-487:

  1. Line 471: writeSSEEvent (wire write) runs first
  2. Lines 474-486: deleteStreamRequestIds + clearStream (cleanup) runs second

The code matches the docstring and changeset. The bonk review misidentified which lines correspond to which operations. No bug here.

Similarly, the bonk review said the changeset references getStreamIdForRequestId when the actual method is getStreamForRequestId. The changeset at line 26 already correctly says McpAgent.getStreamForRequestId. Also not a real issue.

Remaining items worth considering

See inline comments. Nothing blocking — two minor correctness hardening suggestions and one edge case note.

Comment thread packages/agents/src/mcp/transport.ts Outdated
Comment thread packages/agents/src/mcp/transport.ts Outdated
Comment thread packages/agents/src/mcp/transport.ts Outdated
Comment thread packages/agents/src/mcp/event-store.ts
Comment thread packages/agents/src/mcp/transport.ts
Comment thread packages/agents/src/tests/mcp/resumability.test.ts
Comment thread packages/agents/src/tests/mcp/resumability.test.ts
@ask-bonk

ask-bonk Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Review posted. Here's the summary:

The PR is in good shape. The core fixes are correct and well-tested. I verified both "critical" findings from the last bonk review and they were both wrong:

  1. Ordering is correctwriteSSEEvent (line 471) runs before deleteStreamRequestIds + clearStream (lines 474-486). The bonk review misread the line numbers, confusing the comment block with the cleanup code.

  2. Changeset method name is correct — it already says McpAgent.getStreamForRequestId, matching the actual code. The bonk review hallucinated that it said getStreamIdForRequestId.

I left 7 inline comments covering:

  • The synchronous nature of writeSSEEvent being a key property of the write-before-cleanup guarantee
  • A subtle edge case in the no-live-connection + shouldClose path where the comment is slightly misleading
  • A potential throw from close() on dead connections in the stale-connection supersession loop (missing try/catch like sendStandalone has)
  • Storage format migration concern (old { t, m } wrapper vs raw JSONRPCMessage) — likely non-issue but noted
  • Positive notes on the timing guard and replay exclusivity assertions in tests

github run

- Wrap supersede-by-close in try/catch so a dead WS can't abort the
  loop (bonk).
- Trim verbose comments in sendOnStream + send dispatcher. No PR /
  ticket references in code.
@mattzcarey

Copy link
Copy Markdown
Contributor Author

/bonk review this

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

This is a well-considered rewrite of the resumability path. The core fixes are correct and the simplification from alarm-based sweep to immediate cleanup on shouldClose is a clear improvement — less code surface, no idle DO billing, no background state machine to reason about. The changeset is thorough, the tests cover the primary failure mode, and the prior bonk reviews have been largely addressed.

I have a few findings below — one real correctness concern around standalone event accumulation, one gap in the sendForRequest null-connection path that's worth hardening, and a handful of smaller items.

Standalone GET events accumulate without bound

STANDALONE_STREAM_ID events are stored via sendStandalone but never cleaned up — clearStream is only called when shouldClose fires on a POST stream. For long-lived DO sessions with chatty server-initiated notifications, this storage grows without bound for the lifetime of the DO. The doc says "the DO itself dies with the session" which is true for short sessions, but for agents that stay warm for hours/days this could quietly accumulate. Worth either documenting as a known limitation or adding a lightweight cap (e.g. evict oldest standalone events when count exceeds some threshold).

No-live-connection + shouldClose clears events immediately after storing them

When liveConnection is null and shouldClose is true (tool finishes after the client dropped), sendOnStream stores the final event, skips the wire write, then immediately runs deleteStreamRequestIds + clearStream. The stored event exists for the duration of those two awaits. A GET resume arriving in that window would see it; otherwise it's gone. The comment at line 462 acknowledges the race as benign, and that's fair for now — but it means the PR description's claim that "every event up to and including the final response is replayable while the in-flight stream is open" isn't strictly true in this path. The changeset should probably note this edge case explicitly.

Test coverage is good but could be tighter on the standalone resume path

The mid-flight POST test is the right test. The existing "reconnecting GET with a valid Last-Event-ID returns 200" test resumes from a completed POST event id (stream already cleaned up), which is the one-shot replay channel path. There's no test that exercises a true standalone GET disconnect and resume: open standalone GET, receive a server-initiated notification, disconnect, reconnect with Last-Event-ID, trigger another notification, and verify it arrives live. This would exercise the STANDALONE_STREAM_ID branch in handleGetRequest.

// leave a mid-flight client with a wiped stream on reconnect.
// `writeSSEEvent` is sync (enqueues, doesn't await), so the bytes
// are committed before any cleanup await can interleave.
if (liveConnection) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment correctly notes that writeSSEEvent is sync, which is the key property that makes the write-before-cleanup ordering guarantee real. However connection.send() (which writeSSEEvent delegates to) can throw if the WS is dead — and that throw would skip the cleanup block entirely, leaving the stream-reqs mapping and stored events permanently orphaned.

Consider wrapping the write in try/catch so cleanup always runs:

Suggested change
if (liveConnection) {
if (liveConnection) {
try {
this.writeSSEEvent(liveConnection, message, eventId, shouldClose);
} catch (error) {
this.onerror?.(error as Error);
}
}

This way a dead WS doesn't prevent clearStream from running.

await Promise.all(
matchingConnections.map((candidate) =>
this.sendOnStream(agent, candidate, routingError, requestId)
) ?? (matchingConnections.length === 1 ? matchingConnections[0] : null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When matchingConnections is empty and liveConnection is null, the code falls through to getStreamForRequestId. If that also returns undefined (e.g. the stream-reqs were already deleted by a concurrent shouldClose), we throw No active stream found. This is correct.

But there's a subtler case: the getStreamForRequestId lookup succeeds but by the time we reach sendOnStream, a concurrent shouldClose on another call has already run deleteStreamRequestIds + clearStream. The storeEvent in sendOnStream would then write an event to an already-cleared stream that nobody will ever read. Not a bug per se (it's a race between two legitimate paths), but worth noting that the orphaned event persists in storage until the DO dies.

standaloneConnection.state?.streamId ?? standaloneConnection.id;
eventId = await this._eventStore.storeEvent(streamId, message);
}
const eventId = await this._eventStore?.storeEvent(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Standalone events under STANDALONE_STREAM_ID are stored here but never cleared — clearStream only fires on POST streams via shouldClose. For long-lived DOs with chatty server notifications, this grows without bound.

If you don't want to add eviction logic now, at minimum document this as a known limitation in the class docstring or the changeset so future readers know it's intentional.

Comment thread packages/agents/src/mcp/transport.ts Outdated
for (const other of agent.getConnections<TransportConnState>()) {
if (other.id === connection.id) continue;
if (other.state?.streamId !== resumedStreamId) continue;
try {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fix — this directly addresses the stale-connection routing race. The per-connection try/catch is important.

One note: other.close(1000, ...) will trigger onClose on the partyserver side, which may have downstream effects depending on what the agent's onClose handler does. In the typical MCP case this is fine (no onClose override), but it's worth noting that closing stale connections is a side-effectful operation that could interact with custom agent lifecycle hooks.

streamId: StreamId,
message: JSONRPCMessage
): Promise<EventId> {
if (streamId.includes(":")) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good defensive check. This closes the prefix-collision attack surface that the earlier review flagged. The error message with JSON.stringify is helpful for debugging empty strings and special characters.

// semantics. Appending `\x00` (the smallest byte) to `lastKey`
// produces a key that sorts immediately after it, so the list
// result excludes `lastKey` itself without needing a post-filter.
const startKey = `${DurableObjectEventStore.EVENT_KEY_PREFIX}${lastEventId}\x00`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The \x00 approach is cleaner than the previous key <= lastKey filter — it pushes the exclusive boundary into the query itself rather than post-filtering. This also fixes the off-by-one that the third bonk review flagged: the REPLAY_LIMIT now means exactly 1000 replayable events, not 999. The test at event-store.test.ts:278 correctly asserts toBe(1000).

prefix: McpAgent.STREAM_REQS_KEY_PREFIX,
limit: STREAM_REQS_SCAN_LIMIT
});
if (rows.size === STREAM_REQS_SCAN_LIMIT) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The console.warn when hitting the scan limit is a good idea — it turns a silent miss into an observable signal. Worth noting that this warning fires on every send() call in the affected DO, so if there is a leak, logs will be noisy. That's arguably the right trade-off for a situation that "shouldn't happen."

// enough that the tool already completed, the resumed GET would
// have nothing to deliver live and the test would be a false pass.
// We want this to fail loudly instead.
expect(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This timing guard is exactly right — without it, a slow test runner could produce a false pass by completing the tool before the cancel. delayMs: 600 gives a comfortable margin.

One concern: in CI with constrained resources, 600ms might not always be enough if the event loop is blocked. If this test becomes flaky, consider bumping to 1000ms+ or using a coordination mechanism (e.g. the tool signals readiness via a second notification before sleeping).

@ask-bonk

ask-bonk Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Review posted. Here's a summary of what I flagged:

Overall: The PR is in good shape. The core fixes (write-before-cleanup ordering, unconditional event storage, stale-connection supersession, standalone fan-out) are correct. The simplification from alarm-based sweep to immediate cleanup is a clear win. The changeset is thorough and the mid-flight POST test covers the primary failure mode well.

Top-level findings (3):

  1. Standalone GET events accumulate without boundSTANDALONE_STREAM_ID events are never cleared since clearStream only fires on POST streams. For long-lived DOs with chatty server notifications this grows unbounded. Should be documented or capped.

  2. No-live-connection + shouldClose race — When the client drops and the tool finishes, sendOnStream stores the final event then immediately clears it. The event exists only during the two cleanup awaits, making the changeset's claim that "every event is replayable while the stream is open" not strictly true in this path.

  3. Missing standalone GET resume test — No test exercises the STANDALONE_STREAM_ID branch in handleGetRequest (disconnect standalone GET, reconnect with Last-Event-ID, verify live delivery continues).

Inline comments (8):

  • transport.ts:457writeSSEEvent can throw on a dead WS, which would skip cleanup entirely, orphaning the stream-reqs mapping. Suggested wrapping in try/catch.
  • transport.ts:551 — Race note on the getStreamForRequestId fallback path writing orphaned events.
  • transport.ts:504 — The standalone event accumulation concern, inline.
  • transport.ts:248 — Positive note on stale-connection fix, with a caveat about onClose side effects.
  • event-store.ts:67 — Positive note on the : guard.
  • event-store.ts:107 — Positive note on the \x00 approach fixing the off-by-one from the prior review.
  • index.ts:131 — Note on the scan-limit warning being noisy but correctly so.
  • resumability.test.ts:269 — Timing guard is correct; flagged potential CI flakiness if 600ms isn't enough.

github run

Matt Carey added 7 commits June 1, 2026 16:57
…cleanup

If connection.send throws inside writeSSEEvent (WS torn down between
iteration and write), the exception would skip the cleanup block
below and leave stream-reqs + stored events permanently orphaned.

Also document standalone events accumulating for the DO lifetime in
the event-store class docstring.
Reverting the try/catch I added around the supersede-by-close loop.
Closing a WS in the Workers runtime is fire-and-forget and doesn't
throw — no other close() call in the codebase is wrapped (including
the transport's own close() method). Added it reactively to a review
question; the answer was 'no, it doesn't throw'.

Kept the two writeSSEEvent wraps: those guard connection.send (which
the codebase consistently wraps) — one protects the cleanup block
from being skipped, the other keeps one dead WS from aborting
standalone fan-out to the rest.
Two behavioural changes in this PR had no direct coverage:

- sendStandalone fans out to ALL _standaloneSse connections (was
  last-writer-wins). Two new tests: fan-out reaches every standalone
  stream and skips POST bridges; one throwing send doesn't block the
  rest of the loop.
- handleGetRequest closes a stale POST connection when a GET resumes
  its stream. New test asserts the stale bridge is close()d with
  1000/'Superseded by resumed stream', the resuming conn isn't
  closed, and it claims the persisted requestIds.

Mutation-checked: commenting out the supersede close() fails the new
test. 460/460 mcp tests pass.
MCP 2025-06-18 'Multiple Connections': the server MUST send each
JSON-RPC message on only one of the connected streams and MUST NOT
broadcast the same message across multiple streams. The prior commit
fanned standalone notifications out to every _standaloneSse
connection — a direct violation. (The SDK reference enforces the
stronger 'only one standalone GET per session' with a 409.)

Fix:
- handleGetRequest now supersedes prior connections for the resumed
  streamId in ALL resumable cases (POST resume, standalone resume,
  and fresh standalone GET) via a shared supersedePriorStreamConnections
  helper. At most one live connection per stream.
- sendStandalone reverts to single-send: find the one standalone
  connection and write to it, else store-only for replay. No more
  broadcast loop.
- Tests updated: 'sends on exactly one standalone stream',
  'stores but does not write when none live', and a new
  'supersedes a prior standalone GET when a fresh GET opens'.

461/461 mcp tests pass. (5 unrelated typecheck failures in
ai-chat/think predate this branch — from main's recovery work.)
MCP resumability rule: the SSE event id MUST be globally unique
across all streams within a session. We had monotonic-within-stream
and ignore-other-streams coverage but nothing pinning cross-stream
uniqueness. A future move to a global seq counter could silently
break it.

New test stores the same seq across three streams and asserts no id
collides. Mutation-checked: dropping streamId from the id format
fails it.
- 'stripped requestIds' -> stale connections are closed (supersede).
- Replace the fan-out bullet (reverted) with the one-stream-per-message
  spec rule.
- Note standalone GET events accumulate for the DO lifetime.
The variable was renamed to startKey (built inline); the comment
still said 'appending \x00 to lastKey'. No code change.
@threepointone
threepointone merged commit f82d897 into cloudflare:main Jun 1, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants