Skip to content

fix(mcp): SSE resumability for McpAgent + correct keepalive for stateless WorkerTransport (#1583)#1602

Merged
mattzcarey merged 6 commits into
cloudflare:mainfrom
mattzcarey:fix/mcp-sse-keepalive-issue-1583
May 28, 2026
Merged

fix(mcp): SSE resumability for McpAgent + correct keepalive for stateless WorkerTransport (#1583)#1602
mattzcarey merged 6 commits into
cloudflare:mainfrom
mattzcarey:fix/mcp-sse-keepalive-issue-1583

Conversation

@mattzcarey

@mattzcarey mattzcarey commented May 28, 2026

Copy link
Copy Markdown
Contributor

Closes #1583.

Summary

Two-commit fix for SSE keepalive + resumability across both MCP transports.

The policy is now split by stream direction, consistently across McpAgent (utils.ts) and createMcpHandler (worker-transport.ts):

Stream Keepalive? Recovery on idle drop
GET standalone listen stream No Client reconnects with Last-Event-ID against a configured EventStore
POST tool response stream Yes — : keepalive\n\n every 25s None (POST is scoped to a request id and can't be resumed)
Commit What it does
8b0c30e2 Default-enable SSE resumability for McpAgent: new DurableObjectEventStore (storage-backed, hibernation-safe), McpAgent.getEventStore() hook, wired into StreamableHTTPServerTransport (the DurableObjectEventStore itself is @internal, callers override getEventStore() to swap or disable). Also fixes a pre-existing transport bug where resumed GET streams were never re-tagged as the standalone SSE connection.
97f238da Replace the broken event: ping keepalive in both transports with a spec-compliant : keepalive\n\n comment frame, armed only on POST response streams. GET streams now always rely on resumability. Shared helper in sse-keepalive.ts.

What's actually a bug vs. correctness vs. cleanup

Real bugs (user-visible)

  • McpAgent.serve() standalone GET SSE drops at ~270s with no recovery. Reproduced on agents@0.13.2 against a deployed worker — the stream dies at the Cloudflare edge idle watchdog and there is no resumability wired in. Fixed by defaulting EventStore on for McpAgent. (Commit 1)
  • McpAgent's handleGetRequest returned early after replayEvents() without tagging the reconnected connection as the standalone SSE stream. Resumed streams received the backlog and then sat idle — server-initiated notifications had no connection to land on. Pre-existing bug, not in the original report. Fixed by tagging before replay. (Commit 1)
  • McpAgent's POST tool-response SSE had no keepalive. A long-running tool that didn't emit progress would let the response stream sit silent past the ~5min edge watchdog and the client would never get the result. POST streams are scoped to a single request id and can't be replayed, so resumability isn't an option — the only fix is to keepalive them. Fixed in commit 2.
  • Keepalive frame format event: ping\ndata: \n\n is a named SSE event. Per the WHATWG SSE algorithm a data: line with empty value still appends a line feed to the data buffer, so the buffer is non-empty at dispatch time and an event with type="ping" and empty data fires on the client. onmessage doesn't fire (wrong type), but any addEventListener("ping", …) listener does. The MCP TS SDK client skips these (it discards events with empty data) but other SSE clients see them. Fixed by switching to the comment frame : keepalive\n\n. (Commit 2)

Correctness (spec alignment, no behaviour change measured)

  • Interval period 30s on the 30s cancellation boundary. No safety margin. Moved to 25s, which gives headroom below both the post-handler cancellation window and the ~5min edge watchdog. The WHATWG SSE spec recommends "every 15 seconds or so"; 25s is plenty. (Commit 2)
  • Stateless createMcpHandler keepalive cancellation. The reporter argued the Workers runtime cancels the bare setInterval ~30s after the fetch handler returns. I could not reproduce this — a 330s slow tool call survived end-to-end against an unfixed agents@0.13.2 baseline. The open SSE response itself counts as foreground I/O and keeps the worker (and therefore the interval) alive. The frame format and interval period fixes are still correct on their own merits.

Cleanup

  • Shared sse-keepalive.ts helper. Both transports were duplicating the same keepalive logic. Now exported once.

  • JSDoc on WorkerTransportOptions.eventStore now explains the GET-vs-POST split and the BYO recipe.

What's deliberately out of scope

  • Default eventStore for stateless createMcpHandler. Stateless deployments can't ship a sensible default — there's no per-session storage handle available without the caller bringing one. Callers who want resumability supply their own store via WorkerTransportOptions.eventStore. Stateless callers get the corrected POST keepalive by default.
  • ctx.waitUntil threading. Considered briefly to anchor the keepalive against post-handler background-work cancellation, but I couldn't reproduce that scenario empirically and the threading muddied the public API. The streaming SSE response itself keeps the worker alive in practice.
  • Anything for the next-draft MCP spec. This whole code path is being replaced when we target the draft (which removes the GET stream and Last-Event-ID entirely). All of these additions delete cleanly together.

Architecture

This sticks to serverless principles:

  • GET streams hibernate-friendly: the DO is allowed to idle between events, the edge is allowed to close the stream, the client reconnects with Last-Event-ID and the EventStore (backed by this.ctx.storage) replays missed messages. No artificial keepalive holding the DO awake.
  • POST streams are short-lived by nature (single request id, terminates with the response). For the long-running edge cases, the comment-frame keepalive keeps the response alive without firing spurious client-side events.

Compatibility

All changes are additive — patch-level, no breaking changes:

  • McpAgent.getEventStore() is overridable (return undefined to opt out of the default store).
  • No public API signatures changed.

Verification

  • 430/430 MCP tests pass.
  • Deployed repro workers (now torn down) confirmed:
    • Baseline McpAgent: GET drops at 271s with INTERNAL_ERROR, zero bytes received.
    • Fixed McpAgent: GET still drops at 271s (DO is free to hibernate), and a follow-up GET with Last-Event-ID returns 200 OK. Tool-call POST frames now carry id: <streamId>:<seqHex>.
    • Baseline createMcpHandler 330s tool call: completed, but emitted 10 spurious event: ping frames.
    • Fixed createMcpHandler 330s tool call: completed, emitted ~12 : keepalive comment frames the SSE parser discards.

New tests

  • mcp/event-store.test.ts — 10 unit tests for DurableObjectEventStore against a mock DurableObjectStorage (id format, per-stream isolation, eviction, hibernation rehydration, no timers).
  • mcp/resumability.test.ts — 5 e2e tests through the worker entry exercising the McpAgent resumability path.
  • mcp/transports/post-keepalive.test.ts rewritten — asserts GET never arms a keepalive (both with and without eventStore) and POST always does, in both modes.

@changeset-bot

changeset-bot Bot commented May 28, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f85177d

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 found 1 potential issue.

View 5 additional findings in Devin Review.

Open in Devin Review

Comment on lines 468 to 471
cleanup: () => {
clearInterval(keepAlive);
this.streamMapping.delete(streamId);
writer.close().catch(() => {});
}

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.

🟡 Remapped stream cleanup omits stopKeepalive() — incomplete mechanical transformation

When the GET handler remaps the stream ID after replay (line 462-472), the replacement cleanup function does not call stopKeepalive(). The original cleanup at line 438-442 correctly calls it. In the base code, both cleanup functions called clearInterval(keepAlive), so this is an incomplete transformation from the old clearInterval pattern to the new stopKeepalive() pattern.

Currently this is harmless because the remap only happens when this.eventStore is truthy (line 451), and armKeepalive returns a no-op () => {} in that case (worker-transport.ts:299). However, the inconsistency breaks the invariant that every stream's cleanup tears down the keepalive, which could become a real interval leak if armKeepalive is later modified.

Suggested change
cleanup: () => {
clearInterval(keepAlive);
this.streamMapping.delete(streamId);
writer.close().catch(() => {});
}
cleanup: () => {
stopKeepalive();
this.streamMapping.delete(streamId);
writer.close().catch(() => {});
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Matt Carey added 2 commits May 28, 2026 12:17
McpAgent.serve() opens an SSE stream from the Worker to the client that
the Cloudflare edge closes after ~5 minutes of inactivity. The existing
workaround was to write keepalive pings, which would have prevented the
Durable Object from hibernating and contradict the serverless model.

This switches McpAgent to the spec-sanctioned recovery path instead:

  - New `DurableObjectEventStore` that backs the MCP SDK's `EventStore`
    interface with the agent's own DO storage. Bounded at 256 events per
    stream (configurable), namespaced under `__mcp_event__:` to avoid
    user-state collisions, and rehydrates its sequence counter from
    storage after hibernation so reconnects never issue duplicate IDs.

  - `McpAgent` exposes a `getEventStore()` hook (override to disable or
    swap) and `initTransport()` wires the result into
    `StreamableHTTPServerTransport`. Resumability is now on by default.

  - `StreamableHTTPServerTransport.handleGetRequest` now tags the
    reconnected connection as the standalone SSE stream *before* replay.
    Previously the early `return` after `replayEvents` meant a resumed
    stream received only the backlog and then sat idle — server-initiated
    notifications had no connection to land on. Per the 2025-03-26 spec
    the server replays missed messages on the disconnected stream *and*
    continues delivering subsequent messages on the same stream.

Verified end-to-end against a deployed worker: idle GET still drops at
~270s (DO is free to hibernate), and a follow-up GET with Last-Event-ID
returns 200 with no spurious `event: ping` frames.
…1583)

The MCP transports had a `event: ping\ndata: \n\n` keepalive frame that
the SSE parser dispatched as a `MessageEvent` with `type="ping"` and
empty data, firing any `addEventListener("ping", ...)` listener the
client had registered. Per the WHATWG SSE spec the keepalive form is
the comment frame `: ...\n\n`, which the parser drops outright ("If
the line starts with a U+003A COLON character (:), ignore the line").

Beyond the frame format, the previous code armed a keepalive on every
SSE response stream. That made sense for POST tool-call responses
(scoped to a single request id, cannot be resumed), but for the
standalone GET listen stream it forced the server to stay alive
indefinitely instead of letting the edge close idle connections and
relying on `Last-Event-ID` resumption \u2014 which `McpAgent` now defaults
to via `DurableObjectEventStore`.

This commit splits the policy by stream direction:

- GET (standalone listen stream): never keepalive. Idle drops are
  recovered by clients reconnecting with `Last-Event-ID` against the
  configured `EventStore`. `McpAgent` ships one by default;
  `WorkerTransport` callers bring their own.
- POST (tool response stream): always keepalive. The transport writes
  `: keepalive\n\n` every 25s so long-running tool calls survive the
  ~5min Cloudflare edge idle-stream watchdog.

The two transport implementations (`utils.ts` for `McpAgent`,
`worker-transport.ts` for `createMcpHandler`) now share the keepalive
helper in `sse-keepalive.ts`.
@mattzcarey
mattzcarey force-pushed the fix/mcp-sse-keepalive-issue-1583 branch from 149546b to 97f238d Compare May 28, 2026 11:17
…are#1583)

In the GET handler, the standalone SSE stream may be re-mapped under a
different streamId after the eventStore replays missed events. Both the
initial `streamMapping.set` and the remapped one previously inlined
identical cleanup closures, which made it easy to drift if teardown
later grew a new step (e.g. tearing down a keepalive).

Hoist the cleanup into a single `const cleanup` closure used by both
mappings. The closure reads `streamId` lazily so it stays correct after
the remap rebinding. Behaviour unchanged; the invariant that every
mapping shares the same teardown is now structural rather than
copy-paste.

Addresses devin-ai-integration review feedback on PR cloudflare#1602.
case "streamable-http": {
const transport = new StreamableHTTPServerTransport({});
const transport = new StreamableHTTPServerTransport({
eventStore: this.getEventStore()

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.

wondering if this could keep adding stuff to storage over time

the cap is per stream, but it looks like every POST gets a new connection id. so most tool calls would add a couple events to a new stream, never hit the cap, and i can't see anything removing them after the response is done

now that this is on by default for McpAgent, feels like a busy session could just keep building up old POST response events in DO storage

should finished POST streams be cleared, or should there be some cap for the whole session?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

POST streams shouldnt add to storage. Should just be GETs. POST needs the keep alive for this reason. Will double check

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Adding a ttl or some type of cleanup. good shout.

writer.close().catch(() => {});
}
});
// No keepalive on the standalone GET stream. Idle drops are recovered

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.

could this break WorkerTransport setups that don't pass an eventStore?

before this, the GET stream stayed open because of the keepalive. now it can get dropped when idle, but without an eventStore there aren't any ids or replay to recover from that

i think the mcp elicitation example might hit this too since it has a persistent WorkerTransport with storage but no eventStore

feels like GET should keep the new comment keepalive when there isn't an eventStore, and only rely on reconnecting once one is set

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

the keepalive on the GET is just burning cash tho. it should only be for notifications as is being removed real soon. Elicitations go down the POST for the tool call that caused them. If they dont thats a bug that users need to fix.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

feels like GET should keep the new comment keepalive when there isn't an eventStore, and only rely on reconnecting once one is set

will add this this tho. Nice to keep it a patch change.

* edge idle watchdog (~5 min) and never carries a server-side
* keepalive.
*
* POST response streams are scoped to a single request id and can't be

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.

not sure about saying POST streams can't be resumed here

i think with streamable HTTP, if the POST SSE connection drops, the client can reconnect with GET and Last Event ID and get events from that original stream replayed. this code also seems to do that already since it stores the POST events

would be clearer to say the keepalive stops an in progress tool call from getting dropped, instead of saying POST can't be resumed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

will check this. I'm not sure you can resume POST messaged down the reconnected GET cause now they would be orphaned with respect to the tool call they were for.

The default event store is an implementation detail of
McpAgent.getEventStore() and not part of the public API. Drop the
public re-export from agents/mcp, mark the class @internal, and remove
the recommendation to construct it directly from the
WorkerTransportOptions.eventStore JSDoc. Callers who want resumability
on a WorkerTransport bring their own EventStore implementation.
@whoiskatrin
whoiskatrin self-requested a review May 28, 2026 11:57

@whoiskatrin whoiskatrin 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.

Overall looks pretty solid, just a few comments above

Matt Carey added 2 commits May 28, 2026 13:14
Three fixes following review on cloudflare/agents PR cloudflare#1602:

**1. POST stream resumption (transport.ts) — Kate concern 3.**
The previous implementation stored POST events but couldn't actually
resume them: events were keyed by the POST WS connection.id, and the
send() routing looked up connections by their state.requestIds. When
the POST WS died, both were lost; a reconnecting GET created a fresh
WS with a fresh connection.id and no requestIds. Replay reached the
new connection but in-flight tool messages never could.

Fix: introduce a stable streamId on the connection state (seeded with
connection.id for fresh POSTs), persist a streamId -> requestIds
mapping in DO storage, and on resumed GET-with-Last-Event-ID restore
those requestIds onto the new WS. Subsequent send() calls keyed by
requestId now find the resumed connection. New McpAgent helpers
(set/get/deleteStreamRequestIds) wrap the persistence so the transport
doesn't reach into protected ctx.storage directly.

**2. Bounded event-store storage (event-store.ts, index.ts) — Kate concern 1.**
The cap was per-stream, so busy sessions with many short-lived POST
streams under the cap could accumulate events forever in DO storage.

Fix: clear streams cleanly on the final POST response (eventStore
clearStream + deleteStreamRequestIds), and add a TTL safety net for the
unhappy path. DurableObjectEventStore now wraps stored values with a
write timestamp and exposes sweep(maxAgeMs). McpAgent schedules a
recurring sweep via the existing Agent scheduler (default cron */5
minutes, default 1 hr TTL), gated on idempotent: true so duplicate
schedules don't pile up across hibernation/restart.
getEventStoreMaxAgeMs() and getEventStoreSweepCron() expose both knobs.

**3. WorkerTransport GET keepalive when no eventStore (worker-transport.ts) — Kate concern 2.**
Previous behaviour kept GET streams alive via setInterval. New code
relied entirely on resumability, which is a regression for callers
who don't configure an eventStore. Restore the policy: keepalive when
no eventStore (preserve pre-fix behaviour), skip keepalive when one
is configured (resumability is the recovery path). Keeps this PR a
true patch with no behaviour regression.

**4. Re-export DurableObjectEventStore + wire into elicitation example.**
The elicitation example demonstrates the stateful createMcpHandler
pattern (Agent-hosted WorkerTransport with persistent storage). It
needs a concrete EventStore to opt into resumability without writing
its own. Removed @internal, exported from agents/mcp, added
`eventStore: new DurableObjectEventStore(this.ctx.storage)` to the
example.

**5. Fixed stale JSDoc on WorkerTransportOptions.eventStore** that
claimed POST streams can't be resumed. They can.

Tests: 434/434 pass. Added 4 new tests covering sweep behaviour
(deletes old events, no-op on bad input, respects batchSize, restarts
seq after wiping a stream).
…re#1583)

Five-minute sweeps with a one-hour TTL were too aggressive \u2014 cron tick
cost on a busy DO and a tight window for client reconnects. Move to
hourly sweeps (cron `0 * * * *`) and a 24-hour event TTL so abandoned
streams still get garbage-collected but clients have all day to
reconnect with `Last-Event-ID`.

Knobs (`getEventStoreSweepCron`, `getEventStoreMaxAgeMs`) unchanged \u2014
override either to tighten or relax.

@threepointone threepointone 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.

feel free to land after CI passes

@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@1602

@cloudflare/ai-chat

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

@cloudflare/codemode

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

hono-agents

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

@cloudflare/shell

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

@cloudflare/think

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

@cloudflare/voice

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

@cloudflare/worker-bundler

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

commit: f85177d

@mattzcarey
mattzcarey merged commit cfc75bc into cloudflare:main May 28, 2026
4 checks passed
@github-actions github-actions Bot mentioned this pull request May 28, 2026
mattzcarey pushed a commit to mattzcarey/agents that referenced this pull request May 28, 2026
cloudflare#1583 followup)

Reworking the resumability sweep so quiescent McpAgent DOs do no
periodic work, and so the sweep scales to sessions that emit millions
of events.

**The clearStream-before-send race (was: Devin Review on cloudflare#1602).** The
previous code wrote the final POST response into the event store via
storeEvent() and then immediately called clearStream(streamId), wiping
every event for that stream including the one just written. Clients
that lost the connection right as the response was in flight could
reconnect with Last-Event-ID and find an empty store. Drop the
clearStream() call entirely; events persist until either the per-stream
cap evicts them or the on-demand sweep does.

**Unconditional event-store writes on the request-bound send() path.**
The standalone branch already stored events regardless of whether a
live WS was attached. The request-bound branch only stored when a
live connection existed via state.requestIds, dropping mid-flight
tool messages whenever the client lost the WS. Now we fall back to
a persistent requestId -> streamId reverse lookup
(McpAgent.getStreamIdForRequestId, scanning __mcp_stream_reqs__:*) so
the event is recorded even after the originating connection died.
writeSSEEvent only fires when a live connection is still attached;
reconnecting with Last-Event-ID replays everything that was missed.

**Resumed connection no longer plays standalone double-duty.** A GET
reconnecting from a completed POST stream used to get tagged
_standaloneSse: true so it kept receiving notifications, but that
caused future standalone events to store under the dead POST's
streamId and fragment the standalone log. Now the resumed connection
registers under the source streamId and only inherits requestIds if
the source is an active POST. Standalone-stream resumption still gets
the _standaloneSse tag. Matches the SDK reference.

**Stream-level lifetime tracking + on-demand sweep schedule.** Every
storeEvent writes a __mcp_stream_evt_meta__:<streamId> key alongside
the event itself, holding lastWriteAt for that stream. The sweep
scans only this metadata index (O(active streams), not O(total
events)) and deletes entire streams whose last write is past the
configured TTL. At "millions of events per session" scale this is
1000x cheaper than scanning the event log.

The sweep itself is no longer a recurring cron. The store fires an
onStoreEvent hook after every write; the agent uses it to arm an
idempotent one-shot schedule maxAgeMs into the future. The sweep
callback, after running, peeks the oldest remaining stream and either
reschedules at exactly oldest.lastWriteAt + maxAgeMs OR (if nothing
remains) doesn't reschedule at all. Quiescent McpAgents stop ticking
entirely; the first write after a quiet period re-arms.

Knobs: getEventStoreMaxAgeMs (default 24h), getEventStoreSweepMaxIterations
(default 10 pages * batchSize=256 streams per invocation). Removed
getEventStoreSweepCron — no longer applicable.

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

Tests: 457/457 MCP tests pass. New coverage for the sweep return
shape, getOldestStreamWriteAt, onStoreEvent hook, batchSize
pagination, and the replay-after-stream-expired path. npm run check
exit 0 across all 88 projects.
threepointone pushed a commit that referenced this pull request May 28, 2026
…less WorkerTransport (#1583) (#1602)

* fix(mcp): default-enable SSE resumability for McpAgent (#1583)

McpAgent.serve() opens an SSE stream from the Worker to the client that
the Cloudflare edge closes after ~5 minutes of inactivity. The existing
workaround was to write keepalive pings, which would have prevented the
Durable Object from hibernating and contradict the serverless model.

This switches McpAgent to the spec-sanctioned recovery path instead:

  - New `DurableObjectEventStore` that backs the MCP SDK's `EventStore`
    interface with the agent's own DO storage. Bounded at 256 events per
    stream (configurable), namespaced under `__mcp_event__:` to avoid
    user-state collisions, and rehydrates its sequence counter from
    storage after hibernation so reconnects never issue duplicate IDs.

  - `McpAgent` exposes a `getEventStore()` hook (override to disable or
    swap) and `initTransport()` wires the result into
    `StreamableHTTPServerTransport`. Resumability is now on by default.

  - `StreamableHTTPServerTransport.handleGetRequest` now tags the
    reconnected connection as the standalone SSE stream *before* replay.
    Previously the early `return` after `replayEvents` meant a resumed
    stream received only the backlog and then sat idle — server-initiated
    notifications had no connection to land on. Per the 2025-03-26 spec
    the server replays missed messages on the disconnected stream *and*
    continues delivering subsequent messages on the same stream.

Verified end-to-end against a deployed worker: idle GET still drops at
~270s (DO is free to hibernate), and a follow-up GET with Last-Event-ID
returns 200 with no spurious `event: ping` frames.

* fix(mcp): correct SSE keepalive on POST response streams (#1583)

The MCP transports had a `event: ping\ndata: \n\n` keepalive frame that
the SSE parser dispatched as a `MessageEvent` with `type="ping"` and
empty data, firing any `addEventListener("ping", ...)` listener the
client had registered. Per the WHATWG SSE spec the keepalive form is
the comment frame `: ...\n\n`, which the parser drops outright ("If
the line starts with a U+003A COLON character (:), ignore the line").

Beyond the frame format, the previous code armed a keepalive on every
SSE response stream. That made sense for POST tool-call responses
(scoped to a single request id, cannot be resumed), but for the
standalone GET listen stream it forced the server to stay alive
indefinitely instead of letting the edge close idle connections and
relying on `Last-Event-ID` resumption \u2014 which `McpAgent` now defaults
to via `DurableObjectEventStore`.

This commit splits the policy by stream direction:

- GET (standalone listen stream): never keepalive. Idle drops are
  recovered by clients reconnecting with `Last-Event-ID` against the
  configured `EventStore`. `McpAgent` ships one by default;
  `WorkerTransport` callers bring their own.
- POST (tool response stream): always keepalive. The transport writes
  `: keepalive\n\n` every 25s so long-running tool calls survive the
  ~5min Cloudflare edge idle-stream watchdog.

The two transport implementations (`utils.ts` for `McpAgent`,
`worker-transport.ts` for `createMcpHandler`) now share the keepalive
helper in `sse-keepalive.ts`.

* refactor(mcp): share cleanup closure across GET stream remap (#1583)

In the GET handler, the standalone SSE stream may be re-mapped under a
different streamId after the eventStore replays missed events. Both the
initial `streamMapping.set` and the remapped one previously inlined
identical cleanup closures, which made it easy to drift if teardown
later grew a new step (e.g. tearing down a keepalive).

Hoist the cleanup into a single `const cleanup` closure used by both
mappings. The closure reads `streamId` lazily so it stays correct after
the remap rebinding. Behaviour unchanged; the invariant that every
mapping shares the same teardown is now structural rather than
copy-paste.

Addresses devin-ai-integration review feedback on PR #1602.

* chore(mcp): mark DurableObjectEventStore as internal (#1583)

The default event store is an implementation detail of
McpAgent.getEventStore() and not part of the public API. Drop the
public re-export from agents/mcp, mark the class @internal, and remove
the recommendation to construct it directly from the
WorkerTransportOptions.eventStore JSDoc. Callers who want resumability
on a WorkerTransport bring their own EventStore implementation.

* fix(mcp): address Kate's review concerns on PR #1602

Three fixes following review on cloudflare/agents PR #1602:

**1. POST stream resumption (transport.ts) — Kate concern 3.**
The previous implementation stored POST events but couldn't actually
resume them: events were keyed by the POST WS connection.id, and the
send() routing looked up connections by their state.requestIds. When
the POST WS died, both were lost; a reconnecting GET created a fresh
WS with a fresh connection.id and no requestIds. Replay reached the
new connection but in-flight tool messages never could.

Fix: introduce a stable streamId on the connection state (seeded with
connection.id for fresh POSTs), persist a streamId -> requestIds
mapping in DO storage, and on resumed GET-with-Last-Event-ID restore
those requestIds onto the new WS. Subsequent send() calls keyed by
requestId now find the resumed connection. New McpAgent helpers
(set/get/deleteStreamRequestIds) wrap the persistence so the transport
doesn't reach into protected ctx.storage directly.

**2. Bounded event-store storage (event-store.ts, index.ts) — Kate concern 1.**
The cap was per-stream, so busy sessions with many short-lived POST
streams under the cap could accumulate events forever in DO storage.

Fix: clear streams cleanly on the final POST response (eventStore
clearStream + deleteStreamRequestIds), and add a TTL safety net for the
unhappy path. DurableObjectEventStore now wraps stored values with a
write timestamp and exposes sweep(maxAgeMs). McpAgent schedules a
recurring sweep via the existing Agent scheduler (default cron */5
minutes, default 1 hr TTL), gated on idempotent: true so duplicate
schedules don't pile up across hibernation/restart.
getEventStoreMaxAgeMs() and getEventStoreSweepCron() expose both knobs.

**3. WorkerTransport GET keepalive when no eventStore (worker-transport.ts) — Kate concern 2.**
Previous behaviour kept GET streams alive via setInterval. New code
relied entirely on resumability, which is a regression for callers
who don't configure an eventStore. Restore the policy: keepalive when
no eventStore (preserve pre-fix behaviour), skip keepalive when one
is configured (resumability is the recovery path). Keeps this PR a
true patch with no behaviour regression.

**4. Re-export DurableObjectEventStore + wire into elicitation example.**
The elicitation example demonstrates the stateful createMcpHandler
pattern (Agent-hosted WorkerTransport with persistent storage). It
needs a concrete EventStore to opt into resumability without writing
its own. Removed @internal, exported from agents/mcp, added
`eventStore: new DurableObjectEventStore(this.ctx.storage)` to the
example.

**5. Fixed stale JSDoc on WorkerTransportOptions.eventStore** that
claimed POST streams can't be resumed. They can.

Tests: 434/434 pass. Added 4 new tests covering sweep behaviour
(deletes old events, no-op on bad input, respects batchSize, restarts
seq after wiping a stream).

* tune(mcp): default event-store sweep to hourly with 24h TTL (#1583)

Five-minute sweeps with a one-hour TTL were too aggressive \u2014 cron tick
cost on a busy DO and a tight window for client reconnects. Move to
hourly sweeps (cron `0 * * * *`) and a 24-hour event TTL so abandoned
streams still get garbage-collected but clients have all day to
reconnect with `Last-Event-ID`.

Knobs (`getEventStoreSweepCron`, `getEventStoreMaxAgeMs`) unchanged \u2014
override either to tighten or relax.

---------

Co-authored-by: Matt Carey <matt@cloudflare.com>
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.

SSE keepalive missing / broken in McpSession — streams drop every ~5 min on CF edge

3 participants