feat(server): add keepAliveInterval for standalone GET SSE stream - #1726

Open
rcdailey wants to merge 7 commits into
modelcontextprotocol:mainfrom
rcdailey:feat/sse-keepalive-interval
Open

feat(server): add keepAliveInterval for standalone GET SSE stream#1726
rcdailey wants to merge 7 commits into
modelcontextprotocol:mainfrom
rcdailey:feat/sse-keepalive-interval

Conversation

@rcdailey

Copy link
Copy Markdown

Summary

Adds an opt-in keepAliveInterval option to WebStandardStreamableHTTPServerTransportOptions. When set, the transport sends periodic SSE comments (: keepalive\n\n) on the standalone GET SSE stream to reset proxy idle timers and prevent silent disconnections.

SSE comments are ignored by spec-compliant clients but keep the connection alive through reverse proxies (nginx, Envoy, HAProxy, cloud load balancers) that enforce idle timeouts.

Usage

consttransport=newWebStandardStreamableHTTPServerTransport({sessionIdGenerator: ()=>crypto.randomUUID(),keepAliveInterval: 15_000// send keepalive every 15 seconds});

Omitting the option preserves existing behavior (no keepalive, fully backwards compatible).

What changed

  • keepAliveInterval?: number added to the options interface (milliseconds, disabled by default)
  • handleGetRequest starts a setInterval that enqueues : keepalive\n\n to the stream controller
  • Timer is cleared on client disconnect, close(), and closeStandaloneSSEStream()
  • Controller enqueue is guarded against closed/errored state
  • The NodeStreamableHTTPServerTransport wrapper picks this up automatically (it type-aliases and passes through the options)
  • Three new tests covering: comments sent when enabled, no comments when disabled, cleanup on close

Related issues

Ref #28 (server-side ping automation, P1)
Ref #876 (SSE connections drop after ~5 min idle behind proxies)

This is complementary to protocol-level ping approaches (like PR #1717). SSE comments operate at the transport layer and don't require JSON-RPC round-trips, making them a lightweight way to keep the connection alive independently of protocol-level health checks.

@rcdailey
rcdailey requested a review from a team as a code ownerMarch 21, 2026 18:02
@changeset-bot

changeset-botBot commented Mar 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 82ee1a6

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

This PR includes changesets to release 10 packages
NameType
@modelcontextprotocol/serverMinor
@modelcontextprotocol/expressMajor
@modelcontextprotocol/fastifyMajor
@modelcontextprotocol/honoMajor
@modelcontextprotocol/nodeMajor
@modelcontextprotocol/coreMinor
@modelcontextprotocol/clientMinor
@modelcontextprotocol/server-legacyMinor
@modelcontextprotocol/codemodMinor
@modelcontextprotocol/core-internalPatch

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

@pkg-pr-new

pkg-pr-newBot commented Mar 21, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@1726

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@1726

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@1726

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@1726

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@1726

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@1726

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@1726

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@1726

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@1726

commit: 82ee1a6

@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

@felixweinberger
felixweinbergerforce-pushed the feat/sse-keepalive-interval branch from 941226e to 5a925e1CompareMarch 31, 2026 10:31

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

Thanks for picking this up - I think this is a good direction.

Couple of inline comments.

Comment threadpackages/server/src/server/streamableHttp.ts
Comment threadpackages/server/test/server/streamableHttp.test.ts
@km-anthropic

Copy link
Copy Markdown

@claude review

@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from d29e61f to 6a2fd6fCompareMarch 31, 2026 23:39
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Mar 31, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/server/src/server/streamableHttp.ts
Comment threadpackages/server/src/server/streamableHttp.ts
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 1, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from d83942e to 8af2d12CompareApril 1, 2026 12:39
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/server/src/server/streamableHttp.ts
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from 8af2d12 to ccbc773CompareApril 2, 2026 01:09
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/server/src/server/streamableHttp.ts
@rcdailey

Copy link
Copy Markdown
Author

@felixweinberger The bot is identifying a real limitation (single shared _keepAliveTimer field with concurrent streams), but it's the same root cause from last round at a different angle. Properly fixing it means per-stream timer ownership, which is a bigger refactor beyond this PR's scope.

Could you take a look at the current state and let me know if this is mergeable, or if there's anything else you'd like changed?

@felixweinberger

Copy link
Copy Markdown
Contributor

@felixweinberger The bot is identifying a real limitation (single shared _keepAliveTimer field with concurrent streams), but it's the same root cause from last round at a different angle. Properly fixing it means per-stream timer ownership, which is a bigger refactor beyond this PR's scope.

Could you take a look at the current state and let me know if this is mergeable, or if there's anything else you'd like changed?

Yes will take a look thanks

rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from ccbc773 to 1a750b9CompareApril 2, 2026 13:13
@rcdailey

Copy link
Copy Markdown
Author

Thanks Felix. My latest push is a rebase to resolve conflicts with main; no additional changes were made.

@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

@felixweinberger

Copy link
Copy Markdown
Contributor

I think the bot comment is real, pushed a potential fix with an additional regression test

@claudeclaudeBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM — the per-stream keepAliveTimer ownership in StreamMapping correctly resolves the cross-stream timer interference I raised last round, and the new concurrent-stream regression test validates it.

Extended reasoning...

Overview

This PR adds an opt-in keepAliveInterval option to WebStandardStreamableHTTPServerTransport that sends periodic SSE comments to prevent reverse-proxy idle-timeout disconnections. Three files are modified: the changeset descriptor, streamableHttp.ts, and its test file.

Security risks

No security-sensitive code is touched. The feature adds outbound SSE comment writes guarded by a try/catch; there is no new input surface, no auth changes, and no data exposure risk.

Level of scrutiny

This is a new opt-in transport-layer feature that required several rounds of iteration. The logic is now self-contained in handleGetRequest and replayEvents, and the final design (per-stream ownership via StreamMapping.keepAliveTimer) is the correct approach for isolating concurrent stream lifetimes.

Other factors

After four rounds of review and fixes, the substantive bugs have all been addressed: replay-path keepalives were added, the closeStandaloneSSEStream guard was corrected, and the shared-timer cross-cancellation hazard was eliminated by moving to per-stream ownership. A dedicated regression test (should maintain independent keepalive timers per concurrent stream) directly exercises the concurrent scenario. The only open item is a JSDoc documentation nit (inline comment already filed) with no runtime impact. The feature is backwards-compatible and fully opt-in.

Comment on lines +146 to +153
/**
* Interval in milliseconds for sending SSE keepalive comments on the standalone
* GET SSE stream. When set, the transport sends periodic SSE comments
* (`: keepalive`) to prevent reverse proxies from closing idle connections.
*
* Disabled by default (no keepalive comments are sent).
*/
keepAliveInterval?: number;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The JSDoc for keepAliveInterval (line 147) says it sends keepalive comments on the standalone GET SSE stream, but the implementation also starts the timer for reconnecting clients via the Last-Event-ID / replayEvents() path. The description should be updated to reflect that the option applies to all GET-initiated SSE streams, not just the initial standalone one.

Extended reasoning...

What the inaccuracy is

The JSDoc for keepAliveInterval reads: "Interval in milliseconds for sending SSE keepalive comments on the standalone GET SSE stream." This is factually incomplete. The same PR also added keepalive timer support inside replayEvents() (the Last-Event-ID reconnect path), so keepAliveInterval governs keepalive behaviour on both GET stream types.

The specific code evidence

In handleGetRequest() (around line 500 of the diff), when this._keepAliveInterval !== undefined, a setInterval is stored in mapping.keepAliveTimer. In replayEvents() (around line 600), an identical block assigns a local keepAliveTimer and stores it in mapping.keepAliveTimer. Both paths read the same this._keepAliveInterval field. The changeset file .changeset/add-sse-keepalive.md repeats the same partial description: "sends periodic SSE comments on the standalone GET stream".

Why existing code does not prevent confusion

A developer reading only the JSDoc would conclude that reconnecting clients (those sending Last-Event-ID) do not benefit from the keepalive option, and might add their own keepalive logic elsewhere, leading to duplicate timers or misfiled bugs.

Impact

There is no runtime impact -- the code works correctly for both stream types and users actually get more coverage than advertised. The issue is purely documentation: a developer relying on the JSDoc would have an incomplete mental model of the feature scope.

Concrete proof

  1. Developer reads the JSDoc: "sends SSE keepalive comments on the standalone GET SSE stream."
  2. Developer sets keepAliveInterval: 15_000 intending to keep all GET-based connections alive.
  3. A client reconnects with Last-Event-ID -> enters replayEvents() -> timer is started there too.
  4. Developer is unaware replay streams also have keepalives, potentially adding redundant logic or assuming they are missing.

Addressing the refutation

One verifier argued this is not worth reporting because the behavior is more generous than advertised. While true that no user suffers broken behavior, a JSDoc that describes only half of a new feature's scope is a genuine documentation bug. The fix is a one-line word change and the cost of the inaccuracy (misleading developers) outweighs the cost of correcting it.

How to fix

Change "on the standalone GET SSE stream" to "on all GET-initiated SSE streams (both the initial standalone stream and reconnecting Last-Event-ID streams)". Update the changeset description similarly.

@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from 5fcb451 to d490dceCompareMay 22, 2026 13:43
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request May 22, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request May 22, 2026
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey

Copy link
Copy Markdown
Author

@felixweinberger How can I help get this PR merged?

rcdaileyand others added 6 commits July 18, 2026 09:08
Adds an opt-in keepAliveInterval option to
WebStandardStreamableHTTPServerTransportOptions that sends periodic
SSE comments (`: keepalive`) on the standalone GET SSE stream.
Reverse proxies commonly close connections that are idle for 30-60s.
With no server-initiated messages, the GET SSE stream has no traffic
during quiet periods, causing silent disconnections. This option lets
operators send harmless SSE comments at a configurable cadence to
keep the connection alive.
The timer is cleared on close(), closeStandaloneSSEStream(), and on
stream cancellation. Disabled by default; no behavior change for
existing deployments.
Addresses upstream modelcontextprotocol#28, modelcontextprotocol#876.
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
Prevent timer handle leaks when concurrent reconnect requests bypass
the conflict check. This edge case is possible when EventStore omits the
optional getStreamIdForEventId method, allowing duplicate replay
attempts to start new timers without cleaning up existing ones.
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from d490dce to be9c114CompareJuly 18, 2026 14:21
@rcdailey

Copy link
Copy Markdown
Author

@felixweinberger I've rebased onto main and resolved the conflicts with the upstream stale-cancel guards and replay event deduplication logic. Had to fix one interaction between the early-close block (for completed replay requests) and the keepalive timer in your per-stream ownership commit, plus update the concurrent-stream test to account for it.

The CI failure on the previous push was a flaky Cloudflare Workers miniflare test (cloudflareWorkers.test.ts > should handle MCP requests - "Network connection lost"), unrelated to this PR. Main's CI was green at the same time.

I want to be straightforward: this PR has been open since March, and I've rebased and addressed feedback multiple times over the past four months. I don't have the bandwidth to keep maintaining it indefinitely. If there's something blocking the merge, I'd appreciate knowing what it is. Otherwise, this will be my last update before I close the PR.

The early-close block for completed replay requests deletes the stream
mapping entry before the keepalive timer is started. When the client
subsequently closes the stream, the cancel callback's stale-guard check
(which gates timer cleanup on the mapping still pointing at this
controller) cannot find the mapping, leaving the timer running.
- Move clearInterval above the stale-guard check so it always fires for
the closure-local timer
- Guard keepalive timer startup with a mapping-presence check to skip
setup when early-close already removed the entry
- Simulate an in-flight request in the concurrent-stream test so the
replay stream stays open past the early-close block
Sign up for freeto 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.

3 participants

@rcdailey@felixweinberger@km-anthropic
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(server): add keepAliveInterval for standalone GET SSE stream - #1726

Open
rcdailey wants to merge 7 commits into
modelcontextprotocol:mainfrom
rcdailey:feat/sse-keepalive-interval
Open

feat(server): add keepAliveInterval for standalone GET SSE stream#1726
rcdailey wants to merge 7 commits into
modelcontextprotocol:mainfrom
rcdailey:feat/sse-keepalive-interval

Conversation

@rcdailey

Copy link
Copy Markdown

Summary

Adds an opt-in keepAliveInterval option to WebStandardStreamableHTTPServerTransportOptions. When set, the transport sends periodic SSE comments (: keepalive\n\n) on the standalone GET SSE stream to reset proxy idle timers and prevent silent disconnections.

SSE comments are ignored by spec-compliant clients but keep the connection alive through reverse proxies (nginx, Envoy, HAProxy, cloud load balancers) that enforce idle timeouts.

Usage

consttransport=newWebStandardStreamableHTTPServerTransport({sessionIdGenerator: ()=>crypto.randomUUID(),keepAliveInterval: 15_000// send keepalive every 15 seconds});

Omitting the option preserves existing behavior (no keepalive, fully backwards compatible).

What changed

  • keepAliveInterval?: number added to the options interface (milliseconds, disabled by default)
  • handleGetRequest starts a setInterval that enqueues : keepalive\n\n to the stream controller
  • Timer is cleared on client disconnect, close(), and closeStandaloneSSEStream()
  • Controller enqueue is guarded against closed/errored state
  • The NodeStreamableHTTPServerTransport wrapper picks this up automatically (it type-aliases and passes through the options)
  • Three new tests covering: comments sent when enabled, no comments when disabled, cleanup on close

Related issues

Ref #28 (server-side ping automation, P1)
Ref #876 (SSE connections drop after ~5 min idle behind proxies)

This is complementary to protocol-level ping approaches (like PR #1717). SSE comments operate at the transport layer and don't require JSON-RPC round-trips, making them a lightweight way to keep the connection alive independently of protocol-level health checks.

@rcdailey
rcdailey requested a review from a team as a code ownerMarch 21, 2026 18:02
@changeset-bot

changeset-botBot commented Mar 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 82ee1a6

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

This PR includes changesets to release 10 packages
NameType
@modelcontextprotocol/serverMinor
@modelcontextprotocol/expressMajor
@modelcontextprotocol/fastifyMajor
@modelcontextprotocol/honoMajor
@modelcontextprotocol/nodeMajor
@modelcontextprotocol/coreMinor
@modelcontextprotocol/clientMinor
@modelcontextprotocol/server-legacyMinor
@modelcontextprotocol/codemodMinor
@modelcontextprotocol/core-internalPatch

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

@pkg-pr-new

pkg-pr-newBot commented Mar 21, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@1726

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@1726

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@1726

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@1726

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@1726

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@1726

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@1726

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@1726

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@1726

commit: 82ee1a6

@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

@felixweinberger
felixweinbergerforce-pushed the feat/sse-keepalive-interval branch from 941226e to 5a925e1CompareMarch 31, 2026 10:31

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

Thanks for picking this up - I think this is a good direction.

Couple of inline comments.

Comment threadpackages/server/src/server/streamableHttp.ts
Comment threadpackages/server/test/server/streamableHttp.test.ts
@km-anthropic

Copy link
Copy Markdown

@claude review

@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from d29e61f to 6a2fd6fCompareMarch 31, 2026 23:39
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Mar 31, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/server/src/server/streamableHttp.ts
Comment threadpackages/server/src/server/streamableHttp.ts
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 1, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from d83942e to 8af2d12CompareApril 1, 2026 12:39
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/server/src/server/streamableHttp.ts
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from 8af2d12 to ccbc773CompareApril 2, 2026 01:09
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/server/src/server/streamableHttp.ts
@rcdailey

Copy link
Copy Markdown
Author

@felixweinberger The bot is identifying a real limitation (single shared _keepAliveTimer field with concurrent streams), but it's the same root cause from last round at a different angle. Properly fixing it means per-stream timer ownership, which is a bigger refactor beyond this PR's scope.

Could you take a look at the current state and let me know if this is mergeable, or if there's anything else you'd like changed?

@felixweinberger

Copy link
Copy Markdown
Contributor

@felixweinberger The bot is identifying a real limitation (single shared _keepAliveTimer field with concurrent streams), but it's the same root cause from last round at a different angle. Properly fixing it means per-stream timer ownership, which is a bigger refactor beyond this PR's scope.

Could you take a look at the current state and let me know if this is mergeable, or if there's anything else you'd like changed?

Yes will take a look thanks

rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from ccbc773 to 1a750b9CompareApril 2, 2026 13:13
@rcdailey

Copy link
Copy Markdown
Author

Thanks Felix. My latest push is a rebase to resolve conflicts with main; no additional changes were made.

@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

@felixweinberger

Copy link
Copy Markdown
Contributor

I think the bot comment is real, pushed a potential fix with an additional regression test

@claudeclaudeBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM — the per-stream keepAliveTimer ownership in StreamMapping correctly resolves the cross-stream timer interference I raised last round, and the new concurrent-stream regression test validates it.

Extended reasoning...

Overview

This PR adds an opt-in keepAliveInterval option to WebStandardStreamableHTTPServerTransport that sends periodic SSE comments to prevent reverse-proxy idle-timeout disconnections. Three files are modified: the changeset descriptor, streamableHttp.ts, and its test file.

Security risks

No security-sensitive code is touched. The feature adds outbound SSE comment writes guarded by a try/catch; there is no new input surface, no auth changes, and no data exposure risk.

Level of scrutiny

This is a new opt-in transport-layer feature that required several rounds of iteration. The logic is now self-contained in handleGetRequest and replayEvents, and the final design (per-stream ownership via StreamMapping.keepAliveTimer) is the correct approach for isolating concurrent stream lifetimes.

Other factors

After four rounds of review and fixes, the substantive bugs have all been addressed: replay-path keepalives were added, the closeStandaloneSSEStream guard was corrected, and the shared-timer cross-cancellation hazard was eliminated by moving to per-stream ownership. A dedicated regression test (should maintain independent keepalive timers per concurrent stream) directly exercises the concurrent scenario. The only open item is a JSDoc documentation nit (inline comment already filed) with no runtime impact. The feature is backwards-compatible and fully opt-in.

Comment on lines +146 to +153
/**
* Interval in milliseconds for sending SSE keepalive comments on the standalone
* GET SSE stream. When set, the transport sends periodic SSE comments
* (`: keepalive`) to prevent reverse proxies from closing idle connections.
*
* Disabled by default (no keepalive comments are sent).
*/
keepAliveInterval?: number;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The JSDoc for keepAliveInterval (line 147) says it sends keepalive comments on the standalone GET SSE stream, but the implementation also starts the timer for reconnecting clients via the Last-Event-ID / replayEvents() path. The description should be updated to reflect that the option applies to all GET-initiated SSE streams, not just the initial standalone one.

Extended reasoning...

What the inaccuracy is

The JSDoc for keepAliveInterval reads: "Interval in milliseconds for sending SSE keepalive comments on the standalone GET SSE stream." This is factually incomplete. The same PR also added keepalive timer support inside replayEvents() (the Last-Event-ID reconnect path), so keepAliveInterval governs keepalive behaviour on both GET stream types.

The specific code evidence

In handleGetRequest() (around line 500 of the diff), when this._keepAliveInterval !== undefined, a setInterval is stored in mapping.keepAliveTimer. In replayEvents() (around line 600), an identical block assigns a local keepAliveTimer and stores it in mapping.keepAliveTimer. Both paths read the same this._keepAliveInterval field. The changeset file .changeset/add-sse-keepalive.md repeats the same partial description: "sends periodic SSE comments on the standalone GET stream".

Why existing code does not prevent confusion

A developer reading only the JSDoc would conclude that reconnecting clients (those sending Last-Event-ID) do not benefit from the keepalive option, and might add their own keepalive logic elsewhere, leading to duplicate timers or misfiled bugs.

Impact

There is no runtime impact -- the code works correctly for both stream types and users actually get more coverage than advertised. The issue is purely documentation: a developer relying on the JSDoc would have an incomplete mental model of the feature scope.

Concrete proof

  1. Developer reads the JSDoc: "sends SSE keepalive comments on the standalone GET SSE stream."
  2. Developer sets keepAliveInterval: 15_000 intending to keep all GET-based connections alive.
  3. A client reconnects with Last-Event-ID -> enters replayEvents() -> timer is started there too.
  4. Developer is unaware replay streams also have keepalives, potentially adding redundant logic or assuming they are missing.

Addressing the refutation

One verifier argued this is not worth reporting because the behavior is more generous than advertised. While true that no user suffers broken behavior, a JSDoc that describes only half of a new feature's scope is a genuine documentation bug. The fix is a one-line word change and the cost of the inaccuracy (misleading developers) outweighs the cost of correcting it.

How to fix

Change "on the standalone GET SSE stream" to "on all GET-initiated SSE streams (both the initial standalone stream and reconnecting Last-Event-ID streams)". Update the changeset description similarly.

@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from 5fcb451 to d490dceCompareMay 22, 2026 13:43
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request May 22, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request May 22, 2026
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey

Copy link
Copy Markdown
Author

@felixweinberger How can I help get this PR merged?

rcdaileyand others added 6 commits July 18, 2026 09:08
Adds an opt-in keepAliveInterval option to
WebStandardStreamableHTTPServerTransportOptions that sends periodic
SSE comments (`: keepalive`) on the standalone GET SSE stream.
Reverse proxies commonly close connections that are idle for 30-60s.
With no server-initiated messages, the GET SSE stream has no traffic
during quiet periods, causing silent disconnections. This option lets
operators send harmless SSE comments at a configurable cadence to
keep the connection alive.
The timer is cleared on close(), closeStandaloneSSEStream(), and on
stream cancellation. Disabled by default; no behavior change for
existing deployments.
Addresses upstream modelcontextprotocol#28, modelcontextprotocol#876.
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
Prevent timer handle leaks when concurrent reconnect requests bypass
the conflict check. This edge case is possible when EventStore omits the
optional getStreamIdForEventId method, allowing duplicate replay
attempts to start new timers without cleaning up existing ones.
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from d490dce to be9c114CompareJuly 18, 2026 14:21
@rcdailey

Copy link
Copy Markdown
Author

@felixweinberger I've rebased onto main and resolved the conflicts with the upstream stale-cancel guards and replay event deduplication logic. Had to fix one interaction between the early-close block (for completed replay requests) and the keepalive timer in your per-stream ownership commit, plus update the concurrent-stream test to account for it.

The CI failure on the previous push was a flaky Cloudflare Workers miniflare test (cloudflareWorkers.test.ts > should handle MCP requests - "Network connection lost"), unrelated to this PR. Main's CI was green at the same time.

I want to be straightforward: this PR has been open since March, and I've rebased and addressed feedback multiple times over the past four months. I don't have the bandwidth to keep maintaining it indefinitely. If there's something blocking the merge, I'd appreciate knowing what it is. Otherwise, this will be my last update before I close the PR.

The early-close block for completed replay requests deletes the stream
mapping entry before the keepalive timer is started. When the client
subsequently closes the stream, the cancel callback's stale-guard check
(which gates timer cleanup on the mapping still pointing at this
controller) cannot find the mapping, leaving the timer running.
- Move clearInterval above the stale-guard check so it always fires for
the closure-local timer
- Guard keepalive timer startup with a mapping-presence check to skip
setup when early-close already removed the entry
- Simulate an in-flight request in the concurrent-stream test so the
replay stream stays open past the early-close block
Sign up for freeto 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.

3 participants

@rcdailey@felixweinberger@km-anthropic
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(server): add keepAliveInterval for standalone GET SSE stream - #1726

Open
rcdailey wants to merge 7 commits into
modelcontextprotocol:mainfrom
rcdailey:feat/sse-keepalive-interval
Open

feat(server): add keepAliveInterval for standalone GET SSE stream#1726
rcdailey wants to merge 7 commits into
modelcontextprotocol:mainfrom
rcdailey:feat/sse-keepalive-interval

Conversation

@rcdailey

Copy link
Copy Markdown

Summary

Adds an opt-in keepAliveInterval option to WebStandardStreamableHTTPServerTransportOptions. When set, the transport sends periodic SSE comments (: keepalive\n\n) on the standalone GET SSE stream to reset proxy idle timers and prevent silent disconnections.

SSE comments are ignored by spec-compliant clients but keep the connection alive through reverse proxies (nginx, Envoy, HAProxy, cloud load balancers) that enforce idle timeouts.

Usage

consttransport=newWebStandardStreamableHTTPServerTransport({sessionIdGenerator: ()=>crypto.randomUUID(),keepAliveInterval: 15_000// send keepalive every 15 seconds});

Omitting the option preserves existing behavior (no keepalive, fully backwards compatible).

What changed

  • keepAliveInterval?: number added to the options interface (milliseconds, disabled by default)
  • handleGetRequest starts a setInterval that enqueues : keepalive\n\n to the stream controller
  • Timer is cleared on client disconnect, close(), and closeStandaloneSSEStream()
  • Controller enqueue is guarded against closed/errored state
  • The NodeStreamableHTTPServerTransport wrapper picks this up automatically (it type-aliases and passes through the options)
  • Three new tests covering: comments sent when enabled, no comments when disabled, cleanup on close

Related issues

Ref #28 (server-side ping automation, P1)
Ref #876 (SSE connections drop after ~5 min idle behind proxies)

This is complementary to protocol-level ping approaches (like PR #1717). SSE comments operate at the transport layer and don't require JSON-RPC round-trips, making them a lightweight way to keep the connection alive independently of protocol-level health checks.

@rcdailey
rcdailey requested a review from a team as a code ownerMarch 21, 2026 18:02
@changeset-bot

changeset-botBot commented Mar 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 82ee1a6

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

This PR includes changesets to release 10 packages
NameType
@modelcontextprotocol/serverMinor
@modelcontextprotocol/expressMajor
@modelcontextprotocol/fastifyMajor
@modelcontextprotocol/honoMajor
@modelcontextprotocol/nodeMajor
@modelcontextprotocol/coreMinor
@modelcontextprotocol/clientMinor
@modelcontextprotocol/server-legacyMinor
@modelcontextprotocol/codemodMinor
@modelcontextprotocol/core-internalPatch

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

@pkg-pr-new

pkg-pr-newBot commented Mar 21, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@1726

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@1726

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@1726

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@1726

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@1726

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@1726

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@1726

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@1726

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@1726

commit: 82ee1a6

@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

@felixweinberger
felixweinbergerforce-pushed the feat/sse-keepalive-interval branch from 941226e to 5a925e1CompareMarch 31, 2026 10:31

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

Thanks for picking this up - I think this is a good direction.

Couple of inline comments.

Comment threadpackages/server/src/server/streamableHttp.ts
Comment threadpackages/server/test/server/streamableHttp.test.ts
@km-anthropic

Copy link
Copy Markdown

@claude review

@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from d29e61f to 6a2fd6fCompareMarch 31, 2026 23:39
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Mar 31, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/server/src/server/streamableHttp.ts
Comment threadpackages/server/src/server/streamableHttp.ts
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 1, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from d83942e to 8af2d12CompareApril 1, 2026 12:39
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/server/src/server/streamableHttp.ts
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from 8af2d12 to ccbc773CompareApril 2, 2026 01:09
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/server/src/server/streamableHttp.ts
@rcdailey

Copy link
Copy Markdown
Author

@felixweinberger The bot is identifying a real limitation (single shared _keepAliveTimer field with concurrent streams), but it's the same root cause from last round at a different angle. Properly fixing it means per-stream timer ownership, which is a bigger refactor beyond this PR's scope.

Could you take a look at the current state and let me know if this is mergeable, or if there's anything else you'd like changed?

@felixweinberger

Copy link
Copy Markdown
Contributor

@felixweinberger The bot is identifying a real limitation (single shared _keepAliveTimer field with concurrent streams), but it's the same root cause from last round at a different angle. Properly fixing it means per-stream timer ownership, which is a bigger refactor beyond this PR's scope.

Could you take a look at the current state and let me know if this is mergeable, or if there's anything else you'd like changed?

Yes will take a look thanks

rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from ccbc773 to 1a750b9CompareApril 2, 2026 13:13
@rcdailey

Copy link
Copy Markdown
Author

Thanks Felix. My latest push is a rebase to resolve conflicts with main; no additional changes were made.

@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

@felixweinberger

Copy link
Copy Markdown
Contributor

I think the bot comment is real, pushed a potential fix with an additional regression test

@claudeclaudeBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM — the per-stream keepAliveTimer ownership in StreamMapping correctly resolves the cross-stream timer interference I raised last round, and the new concurrent-stream regression test validates it.

Extended reasoning...

Overview

This PR adds an opt-in keepAliveInterval option to WebStandardStreamableHTTPServerTransport that sends periodic SSE comments to prevent reverse-proxy idle-timeout disconnections. Three files are modified: the changeset descriptor, streamableHttp.ts, and its test file.

Security risks

No security-sensitive code is touched. The feature adds outbound SSE comment writes guarded by a try/catch; there is no new input surface, no auth changes, and no data exposure risk.

Level of scrutiny

This is a new opt-in transport-layer feature that required several rounds of iteration. The logic is now self-contained in handleGetRequest and replayEvents, and the final design (per-stream ownership via StreamMapping.keepAliveTimer) is the correct approach for isolating concurrent stream lifetimes.

Other factors

After four rounds of review and fixes, the substantive bugs have all been addressed: replay-path keepalives were added, the closeStandaloneSSEStream guard was corrected, and the shared-timer cross-cancellation hazard was eliminated by moving to per-stream ownership. A dedicated regression test (should maintain independent keepalive timers per concurrent stream) directly exercises the concurrent scenario. The only open item is a JSDoc documentation nit (inline comment already filed) with no runtime impact. The feature is backwards-compatible and fully opt-in.

Comment on lines +146 to +153
/**
* Interval in milliseconds for sending SSE keepalive comments on the standalone
* GET SSE stream. When set, the transport sends periodic SSE comments
* (`: keepalive`) to prevent reverse proxies from closing idle connections.
*
* Disabled by default (no keepalive comments are sent).
*/
keepAliveInterval?: number;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The JSDoc for keepAliveInterval (line 147) says it sends keepalive comments on the standalone GET SSE stream, but the implementation also starts the timer for reconnecting clients via the Last-Event-ID / replayEvents() path. The description should be updated to reflect that the option applies to all GET-initiated SSE streams, not just the initial standalone one.

Extended reasoning...

What the inaccuracy is

The JSDoc for keepAliveInterval reads: "Interval in milliseconds for sending SSE keepalive comments on the standalone GET SSE stream." This is factually incomplete. The same PR also added keepalive timer support inside replayEvents() (the Last-Event-ID reconnect path), so keepAliveInterval governs keepalive behaviour on both GET stream types.

The specific code evidence

In handleGetRequest() (around line 500 of the diff), when this._keepAliveInterval !== undefined, a setInterval is stored in mapping.keepAliveTimer. In replayEvents() (around line 600), an identical block assigns a local keepAliveTimer and stores it in mapping.keepAliveTimer. Both paths read the same this._keepAliveInterval field. The changeset file .changeset/add-sse-keepalive.md repeats the same partial description: "sends periodic SSE comments on the standalone GET stream".

Why existing code does not prevent confusion

A developer reading only the JSDoc would conclude that reconnecting clients (those sending Last-Event-ID) do not benefit from the keepalive option, and might add their own keepalive logic elsewhere, leading to duplicate timers or misfiled bugs.

Impact

There is no runtime impact -- the code works correctly for both stream types and users actually get more coverage than advertised. The issue is purely documentation: a developer relying on the JSDoc would have an incomplete mental model of the feature scope.

Concrete proof

  1. Developer reads the JSDoc: "sends SSE keepalive comments on the standalone GET SSE stream."
  2. Developer sets keepAliveInterval: 15_000 intending to keep all GET-based connections alive.
  3. A client reconnects with Last-Event-ID -> enters replayEvents() -> timer is started there too.
  4. Developer is unaware replay streams also have keepalives, potentially adding redundant logic or assuming they are missing.

Addressing the refutation

One verifier argued this is not worth reporting because the behavior is more generous than advertised. While true that no user suffers broken behavior, a JSDoc that describes only half of a new feature's scope is a genuine documentation bug. The fix is a one-line word change and the cost of the inaccuracy (misleading developers) outweighs the cost of correcting it.

How to fix

Change "on the standalone GET SSE stream" to "on all GET-initiated SSE streams (both the initial standalone stream and reconnecting Last-Event-ID streams)". Update the changeset description similarly.

@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from 5fcb451 to d490dceCompareMay 22, 2026 13:43
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request May 22, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request May 22, 2026
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey

Copy link
Copy Markdown
Author

@felixweinberger How can I help get this PR merged?

rcdaileyand others added 6 commits July 18, 2026 09:08
Adds an opt-in keepAliveInterval option to
WebStandardStreamableHTTPServerTransportOptions that sends periodic
SSE comments (`: keepalive`) on the standalone GET SSE stream.
Reverse proxies commonly close connections that are idle for 30-60s.
With no server-initiated messages, the GET SSE stream has no traffic
during quiet periods, causing silent disconnections. This option lets
operators send harmless SSE comments at a configurable cadence to
keep the connection alive.
The timer is cleared on close(), closeStandaloneSSEStream(), and on
stream cancellation. Disabled by default; no behavior change for
existing deployments.
Addresses upstream modelcontextprotocol#28, modelcontextprotocol#876.
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
Prevent timer handle leaks when concurrent reconnect requests bypass
the conflict check. This edge case is possible when EventStore omits the
optional getStreamIdForEventId method, allowing duplicate replay
attempts to start new timers without cleaning up existing ones.
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from d490dce to be9c114CompareJuly 18, 2026 14:21
@rcdailey

Copy link
Copy Markdown
Author

@felixweinberger I've rebased onto main and resolved the conflicts with the upstream stale-cancel guards and replay event deduplication logic. Had to fix one interaction between the early-close block (for completed replay requests) and the keepalive timer in your per-stream ownership commit, plus update the concurrent-stream test to account for it.

The CI failure on the previous push was a flaky Cloudflare Workers miniflare test (cloudflareWorkers.test.ts > should handle MCP requests - "Network connection lost"), unrelated to this PR. Main's CI was green at the same time.

I want to be straightforward: this PR has been open since March, and I've rebased and addressed feedback multiple times over the past four months. I don't have the bandwidth to keep maintaining it indefinitely. If there's something blocking the merge, I'd appreciate knowing what it is. Otherwise, this will be my last update before I close the PR.

The early-close block for completed replay requests deletes the stream
mapping entry before the keepalive timer is started. When the client
subsequently closes the stream, the cancel callback's stale-guard check
(which gates timer cleanup on the mapping still pointing at this
controller) cannot find the mapping, leaving the timer running.
- Move clearInterval above the stale-guard check so it always fires for
the closure-local timer
- Guard keepalive timer startup with a mapping-presence check to skip
setup when early-close already removed the entry
- Simulate an in-flight request in the concurrent-stream test so the
replay stream stays open past the early-close block
Sign up for freeto 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.

3 participants

@rcdailey@felixweinberger@km-anthropic
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(server): add keepAliveInterval for standalone GET SSE stream - #1726

Open
rcdailey wants to merge 7 commits into
modelcontextprotocol:mainfrom
rcdailey:feat/sse-keepalive-interval
Open

feat(server): add keepAliveInterval for standalone GET SSE stream#1726
rcdailey wants to merge 7 commits into
modelcontextprotocol:mainfrom
rcdailey:feat/sse-keepalive-interval

Conversation

@rcdailey

Copy link
Copy Markdown

Summary

Adds an opt-in keepAliveInterval option to WebStandardStreamableHTTPServerTransportOptions. When set, the transport sends periodic SSE comments (: keepalive\n\n) on the standalone GET SSE stream to reset proxy idle timers and prevent silent disconnections.

SSE comments are ignored by spec-compliant clients but keep the connection alive through reverse proxies (nginx, Envoy, HAProxy, cloud load balancers) that enforce idle timeouts.

Usage

consttransport=newWebStandardStreamableHTTPServerTransport({sessionIdGenerator: ()=>crypto.randomUUID(),keepAliveInterval: 15_000// send keepalive every 15 seconds});

Omitting the option preserves existing behavior (no keepalive, fully backwards compatible).

What changed

  • keepAliveInterval?: number added to the options interface (milliseconds, disabled by default)
  • handleGetRequest starts a setInterval that enqueues : keepalive\n\n to the stream controller
  • Timer is cleared on client disconnect, close(), and closeStandaloneSSEStream()
  • Controller enqueue is guarded against closed/errored state
  • The NodeStreamableHTTPServerTransport wrapper picks this up automatically (it type-aliases and passes through the options)
  • Three new tests covering: comments sent when enabled, no comments when disabled, cleanup on close

Related issues

Ref #28 (server-side ping automation, P1)
Ref #876 (SSE connections drop after ~5 min idle behind proxies)

This is complementary to protocol-level ping approaches (like PR #1717). SSE comments operate at the transport layer and don't require JSON-RPC round-trips, making them a lightweight way to keep the connection alive independently of protocol-level health checks.

@rcdailey
rcdailey requested a review from a team as a code ownerMarch 21, 2026 18:02
@changeset-bot

changeset-botBot commented Mar 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 82ee1a6

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

This PR includes changesets to release 10 packages
NameType
@modelcontextprotocol/serverMinor
@modelcontextprotocol/expressMajor
@modelcontextprotocol/fastifyMajor
@modelcontextprotocol/honoMajor
@modelcontextprotocol/nodeMajor
@modelcontextprotocol/coreMinor
@modelcontextprotocol/clientMinor
@modelcontextprotocol/server-legacyMinor
@modelcontextprotocol/codemodMinor
@modelcontextprotocol/core-internalPatch

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

@pkg-pr-new

pkg-pr-newBot commented Mar 21, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@1726

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@1726

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@1726

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@1726

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@1726

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@1726

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@1726

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@1726

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@1726

commit: 82ee1a6

@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

@felixweinberger
felixweinbergerforce-pushed the feat/sse-keepalive-interval branch from 941226e to 5a925e1CompareMarch 31, 2026 10:31

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

Thanks for picking this up - I think this is a good direction.

Couple of inline comments.

Comment threadpackages/server/src/server/streamableHttp.ts
Comment threadpackages/server/test/server/streamableHttp.test.ts
@km-anthropic

Copy link
Copy Markdown

@claude review

@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from d29e61f to 6a2fd6fCompareMarch 31, 2026 23:39
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Mar 31, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/server/src/server/streamableHttp.ts
Comment threadpackages/server/src/server/streamableHttp.ts
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 1, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from d83942e to 8af2d12CompareApril 1, 2026 12:39
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/server/src/server/streamableHttp.ts
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from 8af2d12 to ccbc773CompareApril 2, 2026 01:09
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/server/src/server/streamableHttp.ts
@rcdailey

Copy link
Copy Markdown
Author

@felixweinberger The bot is identifying a real limitation (single shared _keepAliveTimer field with concurrent streams), but it's the same root cause from last round at a different angle. Properly fixing it means per-stream timer ownership, which is a bigger refactor beyond this PR's scope.

Could you take a look at the current state and let me know if this is mergeable, or if there's anything else you'd like changed?

@felixweinberger

Copy link
Copy Markdown
Contributor

@felixweinberger The bot is identifying a real limitation (single shared _keepAliveTimer field with concurrent streams), but it's the same root cause from last round at a different angle. Properly fixing it means per-stream timer ownership, which is a bigger refactor beyond this PR's scope.

Could you take a look at the current state and let me know if this is mergeable, or if there's anything else you'd like changed?

Yes will take a look thanks

rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from ccbc773 to 1a750b9CompareApril 2, 2026 13:13
@rcdailey

Copy link
Copy Markdown
Author

Thanks Felix. My latest push is a rebase to resolve conflicts with main; no additional changes were made.

@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

@felixweinberger

Copy link
Copy Markdown
Contributor

I think the bot comment is real, pushed a potential fix with an additional regression test

@claudeclaudeBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM — the per-stream keepAliveTimer ownership in StreamMapping correctly resolves the cross-stream timer interference I raised last round, and the new concurrent-stream regression test validates it.

Extended reasoning...

Overview

This PR adds an opt-in keepAliveInterval option to WebStandardStreamableHTTPServerTransport that sends periodic SSE comments to prevent reverse-proxy idle-timeout disconnections. Three files are modified: the changeset descriptor, streamableHttp.ts, and its test file.

Security risks

No security-sensitive code is touched. The feature adds outbound SSE comment writes guarded by a try/catch; there is no new input surface, no auth changes, and no data exposure risk.

Level of scrutiny

This is a new opt-in transport-layer feature that required several rounds of iteration. The logic is now self-contained in handleGetRequest and replayEvents, and the final design (per-stream ownership via StreamMapping.keepAliveTimer) is the correct approach for isolating concurrent stream lifetimes.

Other factors

After four rounds of review and fixes, the substantive bugs have all been addressed: replay-path keepalives were added, the closeStandaloneSSEStream guard was corrected, and the shared-timer cross-cancellation hazard was eliminated by moving to per-stream ownership. A dedicated regression test (should maintain independent keepalive timers per concurrent stream) directly exercises the concurrent scenario. The only open item is a JSDoc documentation nit (inline comment already filed) with no runtime impact. The feature is backwards-compatible and fully opt-in.

Comment on lines +146 to +153
/**
* Interval in milliseconds for sending SSE keepalive comments on the standalone
* GET SSE stream. When set, the transport sends periodic SSE comments
* (`: keepalive`) to prevent reverse proxies from closing idle connections.
*
* Disabled by default (no keepalive comments are sent).
*/
keepAliveInterval?: number;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The JSDoc for keepAliveInterval (line 147) says it sends keepalive comments on the standalone GET SSE stream, but the implementation also starts the timer for reconnecting clients via the Last-Event-ID / replayEvents() path. The description should be updated to reflect that the option applies to all GET-initiated SSE streams, not just the initial standalone one.

Extended reasoning...

What the inaccuracy is

The JSDoc for keepAliveInterval reads: "Interval in milliseconds for sending SSE keepalive comments on the standalone GET SSE stream." This is factually incomplete. The same PR also added keepalive timer support inside replayEvents() (the Last-Event-ID reconnect path), so keepAliveInterval governs keepalive behaviour on both GET stream types.

The specific code evidence

In handleGetRequest() (around line 500 of the diff), when this._keepAliveInterval !== undefined, a setInterval is stored in mapping.keepAliveTimer. In replayEvents() (around line 600), an identical block assigns a local keepAliveTimer and stores it in mapping.keepAliveTimer. Both paths read the same this._keepAliveInterval field. The changeset file .changeset/add-sse-keepalive.md repeats the same partial description: "sends periodic SSE comments on the standalone GET stream".

Why existing code does not prevent confusion

A developer reading only the JSDoc would conclude that reconnecting clients (those sending Last-Event-ID) do not benefit from the keepalive option, and might add their own keepalive logic elsewhere, leading to duplicate timers or misfiled bugs.

Impact

There is no runtime impact -- the code works correctly for both stream types and users actually get more coverage than advertised. The issue is purely documentation: a developer relying on the JSDoc would have an incomplete mental model of the feature scope.

Concrete proof

  1. Developer reads the JSDoc: "sends SSE keepalive comments on the standalone GET SSE stream."
  2. Developer sets keepAliveInterval: 15_000 intending to keep all GET-based connections alive.
  3. A client reconnects with Last-Event-ID -> enters replayEvents() -> timer is started there too.
  4. Developer is unaware replay streams also have keepalives, potentially adding redundant logic or assuming they are missing.

Addressing the refutation

One verifier argued this is not worth reporting because the behavior is more generous than advertised. While true that no user suffers broken behavior, a JSDoc that describes only half of a new feature's scope is a genuine documentation bug. The fix is a one-line word change and the cost of the inaccuracy (misleading developers) outweighs the cost of correcting it.

How to fix

Change "on the standalone GET SSE stream" to "on all GET-initiated SSE streams (both the initial standalone stream and reconnecting Last-Event-ID streams)". Update the changeset description similarly.

@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from 5fcb451 to d490dceCompareMay 22, 2026 13:43
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request May 22, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request May 22, 2026
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey

Copy link
Copy Markdown
Author

@felixweinberger How can I help get this PR merged?

rcdaileyand others added 6 commits July 18, 2026 09:08
Adds an opt-in keepAliveInterval option to
WebStandardStreamableHTTPServerTransportOptions that sends periodic
SSE comments (`: keepalive`) on the standalone GET SSE stream.
Reverse proxies commonly close connections that are idle for 30-60s.
With no server-initiated messages, the GET SSE stream has no traffic
during quiet periods, causing silent disconnections. This option lets
operators send harmless SSE comments at a configurable cadence to
keep the connection alive.
The timer is cleared on close(), closeStandaloneSSEStream(), and on
stream cancellation. Disabled by default; no behavior change for
existing deployments.
Addresses upstream modelcontextprotocol#28, modelcontextprotocol#876.
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
Prevent timer handle leaks when concurrent reconnect requests bypass
the conflict check. This edge case is possible when EventStore omits the
optional getStreamIdForEventId method, allowing duplicate replay
attempts to start new timers without cleaning up existing ones.
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from d490dce to be9c114CompareJuly 18, 2026 14:21
@rcdailey

Copy link
Copy Markdown
Author

@felixweinberger I've rebased onto main and resolved the conflicts with the upstream stale-cancel guards and replay event deduplication logic. Had to fix one interaction between the early-close block (for completed replay requests) and the keepalive timer in your per-stream ownership commit, plus update the concurrent-stream test to account for it.

The CI failure on the previous push was a flaky Cloudflare Workers miniflare test (cloudflareWorkers.test.ts > should handle MCP requests - "Network connection lost"), unrelated to this PR. Main's CI was green at the same time.

I want to be straightforward: this PR has been open since March, and I've rebased and addressed feedback multiple times over the past four months. I don't have the bandwidth to keep maintaining it indefinitely. If there's something blocking the merge, I'd appreciate knowing what it is. Otherwise, this will be my last update before I close the PR.

The early-close block for completed replay requests deletes the stream
mapping entry before the keepalive timer is started. When the client
subsequently closes the stream, the cancel callback's stale-guard check
(which gates timer cleanup on the mapping still pointing at this
controller) cannot find the mapping, leaving the timer running.
- Move clearInterval above the stale-guard check so it always fires for
the closure-local timer
- Guard keepalive timer startup with a mapping-presence check to skip
setup when early-close already removed the entry
- Simulate an in-flight request in the concurrent-stream test so the
replay stream stays open past the early-close block
Sign up for freeto 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.

3 participants

@rcdailey@felixweinberger@km-anthropic
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(server): add keepAliveInterval for standalone GET SSE stream - #1726

Open
rcdailey wants to merge 7 commits into
modelcontextprotocol:mainfrom
rcdailey:feat/sse-keepalive-interval
Open

feat(server): add keepAliveInterval for standalone GET SSE stream#1726
rcdailey wants to merge 7 commits into
modelcontextprotocol:mainfrom
rcdailey:feat/sse-keepalive-interval

Conversation

@rcdailey

Copy link
Copy Markdown

Summary

Adds an opt-in keepAliveInterval option to WebStandardStreamableHTTPServerTransportOptions. When set, the transport sends periodic SSE comments (: keepalive\n\n) on the standalone GET SSE stream to reset proxy idle timers and prevent silent disconnections.

SSE comments are ignored by spec-compliant clients but keep the connection alive through reverse proxies (nginx, Envoy, HAProxy, cloud load balancers) that enforce idle timeouts.

Usage

consttransport=newWebStandardStreamableHTTPServerTransport({sessionIdGenerator: ()=>crypto.randomUUID(),keepAliveInterval: 15_000// send keepalive every 15 seconds});

Omitting the option preserves existing behavior (no keepalive, fully backwards compatible).

What changed

  • keepAliveInterval?: number added to the options interface (milliseconds, disabled by default)
  • handleGetRequest starts a setInterval that enqueues : keepalive\n\n to the stream controller
  • Timer is cleared on client disconnect, close(), and closeStandaloneSSEStream()
  • Controller enqueue is guarded against closed/errored state
  • The NodeStreamableHTTPServerTransport wrapper picks this up automatically (it type-aliases and passes through the options)
  • Three new tests covering: comments sent when enabled, no comments when disabled, cleanup on close

Related issues

Ref #28 (server-side ping automation, P1)
Ref #876 (SSE connections drop after ~5 min idle behind proxies)

This is complementary to protocol-level ping approaches (like PR #1717). SSE comments operate at the transport layer and don't require JSON-RPC round-trips, making them a lightweight way to keep the connection alive independently of protocol-level health checks.

@rcdailey
rcdailey requested a review from a team as a code ownerMarch 21, 2026 18:02
@changeset-bot

changeset-botBot commented Mar 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 82ee1a6

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

This PR includes changesets to release 10 packages
NameType
@modelcontextprotocol/serverMinor
@modelcontextprotocol/expressMajor
@modelcontextprotocol/fastifyMajor
@modelcontextprotocol/honoMajor
@modelcontextprotocol/nodeMajor
@modelcontextprotocol/coreMinor
@modelcontextprotocol/clientMinor
@modelcontextprotocol/server-legacyMinor
@modelcontextprotocol/codemodMinor
@modelcontextprotocol/core-internalPatch

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

@pkg-pr-new

pkg-pr-newBot commented Mar 21, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@1726

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@1726

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@1726

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@1726

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@1726

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@1726

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@1726

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@1726

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@1726

commit: 82ee1a6

@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

@felixweinberger
felixweinbergerforce-pushed the feat/sse-keepalive-interval branch from 941226e to 5a925e1CompareMarch 31, 2026 10:31

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

Thanks for picking this up - I think this is a good direction.

Couple of inline comments.

Comment threadpackages/server/src/server/streamableHttp.ts
Comment threadpackages/server/test/server/streamableHttp.test.ts
@km-anthropic

Copy link
Copy Markdown

@claude review

@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from d29e61f to 6a2fd6fCompareMarch 31, 2026 23:39
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Mar 31, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/server/src/server/streamableHttp.ts
Comment threadpackages/server/src/server/streamableHttp.ts
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 1, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from d83942e to 8af2d12CompareApril 1, 2026 12:39
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/server/src/server/streamableHttp.ts
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from 8af2d12 to ccbc773CompareApril 2, 2026 01:09
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/server/src/server/streamableHttp.ts
@rcdailey

Copy link
Copy Markdown
Author

@felixweinberger The bot is identifying a real limitation (single shared _keepAliveTimer field with concurrent streams), but it's the same root cause from last round at a different angle. Properly fixing it means per-stream timer ownership, which is a bigger refactor beyond this PR's scope.

Could you take a look at the current state and let me know if this is mergeable, or if there's anything else you'd like changed?

@felixweinberger

Copy link
Copy Markdown
Contributor

@felixweinberger The bot is identifying a real limitation (single shared _keepAliveTimer field with concurrent streams), but it's the same root cause from last round at a different angle. Properly fixing it means per-stream timer ownership, which is a bigger refactor beyond this PR's scope.

Could you take a look at the current state and let me know if this is mergeable, or if there's anything else you'd like changed?

Yes will take a look thanks

rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from ccbc773 to 1a750b9CompareApril 2, 2026 13:13
@rcdailey

Copy link
Copy Markdown
Author

Thanks Felix. My latest push is a rebase to resolve conflicts with main; no additional changes were made.

@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

@felixweinberger

Copy link
Copy Markdown
Contributor

I think the bot comment is real, pushed a potential fix with an additional regression test

@claudeclaudeBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM — the per-stream keepAliveTimer ownership in StreamMapping correctly resolves the cross-stream timer interference I raised last round, and the new concurrent-stream regression test validates it.

Extended reasoning...

Overview

This PR adds an opt-in keepAliveInterval option to WebStandardStreamableHTTPServerTransport that sends periodic SSE comments to prevent reverse-proxy idle-timeout disconnections. Three files are modified: the changeset descriptor, streamableHttp.ts, and its test file.

Security risks

No security-sensitive code is touched. The feature adds outbound SSE comment writes guarded by a try/catch; there is no new input surface, no auth changes, and no data exposure risk.

Level of scrutiny

This is a new opt-in transport-layer feature that required several rounds of iteration. The logic is now self-contained in handleGetRequest and replayEvents, and the final design (per-stream ownership via StreamMapping.keepAliveTimer) is the correct approach for isolating concurrent stream lifetimes.

Other factors

After four rounds of review and fixes, the substantive bugs have all been addressed: replay-path keepalives were added, the closeStandaloneSSEStream guard was corrected, and the shared-timer cross-cancellation hazard was eliminated by moving to per-stream ownership. A dedicated regression test (should maintain independent keepalive timers per concurrent stream) directly exercises the concurrent scenario. The only open item is a JSDoc documentation nit (inline comment already filed) with no runtime impact. The feature is backwards-compatible and fully opt-in.

Comment on lines +146 to +153
/**
* Interval in milliseconds for sending SSE keepalive comments on the standalone
* GET SSE stream. When set, the transport sends periodic SSE comments
* (`: keepalive`) to prevent reverse proxies from closing idle connections.
*
* Disabled by default (no keepalive comments are sent).
*/
keepAliveInterval?: number;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The JSDoc for keepAliveInterval (line 147) says it sends keepalive comments on the standalone GET SSE stream, but the implementation also starts the timer for reconnecting clients via the Last-Event-ID / replayEvents() path. The description should be updated to reflect that the option applies to all GET-initiated SSE streams, not just the initial standalone one.

Extended reasoning...

What the inaccuracy is

The JSDoc for keepAliveInterval reads: "Interval in milliseconds for sending SSE keepalive comments on the standalone GET SSE stream." This is factually incomplete. The same PR also added keepalive timer support inside replayEvents() (the Last-Event-ID reconnect path), so keepAliveInterval governs keepalive behaviour on both GET stream types.

The specific code evidence

In handleGetRequest() (around line 500 of the diff), when this._keepAliveInterval !== undefined, a setInterval is stored in mapping.keepAliveTimer. In replayEvents() (around line 600), an identical block assigns a local keepAliveTimer and stores it in mapping.keepAliveTimer. Both paths read the same this._keepAliveInterval field. The changeset file .changeset/add-sse-keepalive.md repeats the same partial description: "sends periodic SSE comments on the standalone GET stream".

Why existing code does not prevent confusion

A developer reading only the JSDoc would conclude that reconnecting clients (those sending Last-Event-ID) do not benefit from the keepalive option, and might add their own keepalive logic elsewhere, leading to duplicate timers or misfiled bugs.

Impact

There is no runtime impact -- the code works correctly for both stream types and users actually get more coverage than advertised. The issue is purely documentation: a developer relying on the JSDoc would have an incomplete mental model of the feature scope.

Concrete proof

  1. Developer reads the JSDoc: "sends SSE keepalive comments on the standalone GET SSE stream."
  2. Developer sets keepAliveInterval: 15_000 intending to keep all GET-based connections alive.
  3. A client reconnects with Last-Event-ID -> enters replayEvents() -> timer is started there too.
  4. Developer is unaware replay streams also have keepalives, potentially adding redundant logic or assuming they are missing.

Addressing the refutation

One verifier argued this is not worth reporting because the behavior is more generous than advertised. While true that no user suffers broken behavior, a JSDoc that describes only half of a new feature's scope is a genuine documentation bug. The fix is a one-line word change and the cost of the inaccuracy (misleading developers) outweighs the cost of correcting it.

How to fix

Change "on the standalone GET SSE stream" to "on all GET-initiated SSE streams (both the initial standalone stream and reconnecting Last-Event-ID streams)". Update the changeset description similarly.

@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from 5fcb451 to d490dceCompareMay 22, 2026 13:43
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request May 22, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request May 22, 2026
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey

Copy link
Copy Markdown
Author

@felixweinberger How can I help get this PR merged?

rcdaileyand others added 6 commits July 18, 2026 09:08
Adds an opt-in keepAliveInterval option to
WebStandardStreamableHTTPServerTransportOptions that sends periodic
SSE comments (`: keepalive`) on the standalone GET SSE stream.
Reverse proxies commonly close connections that are idle for 30-60s.
With no server-initiated messages, the GET SSE stream has no traffic
during quiet periods, causing silent disconnections. This option lets
operators send harmless SSE comments at a configurable cadence to
keep the connection alive.
The timer is cleared on close(), closeStandaloneSSEStream(), and on
stream cancellation. Disabled by default; no behavior change for
existing deployments.
Addresses upstream modelcontextprotocol#28, modelcontextprotocol#876.
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
Prevent timer handle leaks when concurrent reconnect requests bypass
the conflict check. This edge case is possible when EventStore omits the
optional getStreamIdForEventId method, allowing duplicate replay
attempts to start new timers without cleaning up existing ones.
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from d490dce to be9c114CompareJuly 18, 2026 14:21
@rcdailey

Copy link
Copy Markdown
Author

@felixweinberger I've rebased onto main and resolved the conflicts with the upstream stale-cancel guards and replay event deduplication logic. Had to fix one interaction between the early-close block (for completed replay requests) and the keepalive timer in your per-stream ownership commit, plus update the concurrent-stream test to account for it.

The CI failure on the previous push was a flaky Cloudflare Workers miniflare test (cloudflareWorkers.test.ts > should handle MCP requests - "Network connection lost"), unrelated to this PR. Main's CI was green at the same time.

I want to be straightforward: this PR has been open since March, and I've rebased and addressed feedback multiple times over the past four months. I don't have the bandwidth to keep maintaining it indefinitely. If there's something blocking the merge, I'd appreciate knowing what it is. Otherwise, this will be my last update before I close the PR.

The early-close block for completed replay requests deletes the stream
mapping entry before the keepalive timer is started. When the client
subsequently closes the stream, the cancel callback's stale-guard check
(which gates timer cleanup on the mapping still pointing at this
controller) cannot find the mapping, leaving the timer running.
- Move clearInterval above the stale-guard check so it always fires for
the closure-local timer
- Guard keepalive timer startup with a mapping-presence check to skip
setup when early-close already removed the entry
- Simulate an in-flight request in the concurrent-stream test so the
replay stream stays open past the early-close block
Sign up for freeto 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.

3 participants

@rcdailey@felixweinberger@km-anthropic
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(server): add keepAliveInterval for standalone GET SSE stream - #1726

Open
rcdailey wants to merge 7 commits into
modelcontextprotocol:mainfrom
rcdailey:feat/sse-keepalive-interval
Open

feat(server): add keepAliveInterval for standalone GET SSE stream#1726
rcdailey wants to merge 7 commits into
modelcontextprotocol:mainfrom
rcdailey:feat/sse-keepalive-interval

Conversation

@rcdailey

Copy link
Copy Markdown

Summary

Adds an opt-in keepAliveInterval option to WebStandardStreamableHTTPServerTransportOptions. When set, the transport sends periodic SSE comments (: keepalive\n\n) on the standalone GET SSE stream to reset proxy idle timers and prevent silent disconnections.

SSE comments are ignored by spec-compliant clients but keep the connection alive through reverse proxies (nginx, Envoy, HAProxy, cloud load balancers) that enforce idle timeouts.

Usage

consttransport=newWebStandardStreamableHTTPServerTransport({sessionIdGenerator: ()=>crypto.randomUUID(),keepAliveInterval: 15_000// send keepalive every 15 seconds});

Omitting the option preserves existing behavior (no keepalive, fully backwards compatible).

What changed

  • keepAliveInterval?: number added to the options interface (milliseconds, disabled by default)
  • handleGetRequest starts a setInterval that enqueues : keepalive\n\n to the stream controller
  • Timer is cleared on client disconnect, close(), and closeStandaloneSSEStream()
  • Controller enqueue is guarded against closed/errored state
  • The NodeStreamableHTTPServerTransport wrapper picks this up automatically (it type-aliases and passes through the options)
  • Three new tests covering: comments sent when enabled, no comments when disabled, cleanup on close

Related issues

Ref #28 (server-side ping automation, P1)
Ref #876 (SSE connections drop after ~5 min idle behind proxies)

This is complementary to protocol-level ping approaches (like PR #1717). SSE comments operate at the transport layer and don't require JSON-RPC round-trips, making them a lightweight way to keep the connection alive independently of protocol-level health checks.

@rcdailey
rcdailey requested a review from a team as a code ownerMarch 21, 2026 18:02
@changeset-bot

changeset-botBot commented Mar 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 82ee1a6

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

This PR includes changesets to release 10 packages
NameType
@modelcontextprotocol/serverMinor
@modelcontextprotocol/expressMajor
@modelcontextprotocol/fastifyMajor
@modelcontextprotocol/honoMajor
@modelcontextprotocol/nodeMajor
@modelcontextprotocol/coreMinor
@modelcontextprotocol/clientMinor
@modelcontextprotocol/server-legacyMinor
@modelcontextprotocol/codemodMinor
@modelcontextprotocol/core-internalPatch

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

@pkg-pr-new

pkg-pr-newBot commented Mar 21, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@1726

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@1726

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@1726

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@1726

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@1726

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@1726

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@1726

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@1726

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@1726

commit: 82ee1a6

@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

@felixweinberger
felixweinbergerforce-pushed the feat/sse-keepalive-interval branch from 941226e to 5a925e1CompareMarch 31, 2026 10:31

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

Thanks for picking this up - I think this is a good direction.

Couple of inline comments.

Comment threadpackages/server/src/server/streamableHttp.ts
Comment threadpackages/server/test/server/streamableHttp.test.ts
@km-anthropic

Copy link
Copy Markdown

@claude review

@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from d29e61f to 6a2fd6fCompareMarch 31, 2026 23:39
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Mar 31, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/server/src/server/streamableHttp.ts
Comment threadpackages/server/src/server/streamableHttp.ts
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 1, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from d83942e to 8af2d12CompareApril 1, 2026 12:39
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/server/src/server/streamableHttp.ts
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from 8af2d12 to ccbc773CompareApril 2, 2026 01:09
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/server/src/server/streamableHttp.ts
@rcdailey

Copy link
Copy Markdown
Author

@felixweinberger The bot is identifying a real limitation (single shared _keepAliveTimer field with concurrent streams), but it's the same root cause from last round at a different angle. Properly fixing it means per-stream timer ownership, which is a bigger refactor beyond this PR's scope.

Could you take a look at the current state and let me know if this is mergeable, or if there's anything else you'd like changed?

@felixweinberger

Copy link
Copy Markdown
Contributor

@felixweinberger The bot is identifying a real limitation (single shared _keepAliveTimer field with concurrent streams), but it's the same root cause from last round at a different angle. Properly fixing it means per-stream timer ownership, which is a bigger refactor beyond this PR's scope.

Could you take a look at the current state and let me know if this is mergeable, or if there's anything else you'd like changed?

Yes will take a look thanks

rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from ccbc773 to 1a750b9CompareApril 2, 2026 13:13
@rcdailey

Copy link
Copy Markdown
Author

Thanks Felix. My latest push is a rebase to resolve conflicts with main; no additional changes were made.

@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

@felixweinberger

Copy link
Copy Markdown
Contributor

I think the bot comment is real, pushed a potential fix with an additional regression test

@claudeclaudeBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM — the per-stream keepAliveTimer ownership in StreamMapping correctly resolves the cross-stream timer interference I raised last round, and the new concurrent-stream regression test validates it.

Extended reasoning...

Overview

This PR adds an opt-in keepAliveInterval option to WebStandardStreamableHTTPServerTransport that sends periodic SSE comments to prevent reverse-proxy idle-timeout disconnections. Three files are modified: the changeset descriptor, streamableHttp.ts, and its test file.

Security risks

No security-sensitive code is touched. The feature adds outbound SSE comment writes guarded by a try/catch; there is no new input surface, no auth changes, and no data exposure risk.

Level of scrutiny

This is a new opt-in transport-layer feature that required several rounds of iteration. The logic is now self-contained in handleGetRequest and replayEvents, and the final design (per-stream ownership via StreamMapping.keepAliveTimer) is the correct approach for isolating concurrent stream lifetimes.

Other factors

After four rounds of review and fixes, the substantive bugs have all been addressed: replay-path keepalives were added, the closeStandaloneSSEStream guard was corrected, and the shared-timer cross-cancellation hazard was eliminated by moving to per-stream ownership. A dedicated regression test (should maintain independent keepalive timers per concurrent stream) directly exercises the concurrent scenario. The only open item is a JSDoc documentation nit (inline comment already filed) with no runtime impact. The feature is backwards-compatible and fully opt-in.

Comment on lines +146 to +153
/**
* Interval in milliseconds for sending SSE keepalive comments on the standalone
* GET SSE stream. When set, the transport sends periodic SSE comments
* (`: keepalive`) to prevent reverse proxies from closing idle connections.
*
* Disabled by default (no keepalive comments are sent).
*/
keepAliveInterval?: number;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The JSDoc for keepAliveInterval (line 147) says it sends keepalive comments on the standalone GET SSE stream, but the implementation also starts the timer for reconnecting clients via the Last-Event-ID / replayEvents() path. The description should be updated to reflect that the option applies to all GET-initiated SSE streams, not just the initial standalone one.

Extended reasoning...

What the inaccuracy is

The JSDoc for keepAliveInterval reads: "Interval in milliseconds for sending SSE keepalive comments on the standalone GET SSE stream." This is factually incomplete. The same PR also added keepalive timer support inside replayEvents() (the Last-Event-ID reconnect path), so keepAliveInterval governs keepalive behaviour on both GET stream types.

The specific code evidence

In handleGetRequest() (around line 500 of the diff), when this._keepAliveInterval !== undefined, a setInterval is stored in mapping.keepAliveTimer. In replayEvents() (around line 600), an identical block assigns a local keepAliveTimer and stores it in mapping.keepAliveTimer. Both paths read the same this._keepAliveInterval field. The changeset file .changeset/add-sse-keepalive.md repeats the same partial description: "sends periodic SSE comments on the standalone GET stream".

Why existing code does not prevent confusion

A developer reading only the JSDoc would conclude that reconnecting clients (those sending Last-Event-ID) do not benefit from the keepalive option, and might add their own keepalive logic elsewhere, leading to duplicate timers or misfiled bugs.

Impact

There is no runtime impact -- the code works correctly for both stream types and users actually get more coverage than advertised. The issue is purely documentation: a developer relying on the JSDoc would have an incomplete mental model of the feature scope.

Concrete proof

  1. Developer reads the JSDoc: "sends SSE keepalive comments on the standalone GET SSE stream."
  2. Developer sets keepAliveInterval: 15_000 intending to keep all GET-based connections alive.
  3. A client reconnects with Last-Event-ID -> enters replayEvents() -> timer is started there too.
  4. Developer is unaware replay streams also have keepalives, potentially adding redundant logic or assuming they are missing.

Addressing the refutation

One verifier argued this is not worth reporting because the behavior is more generous than advertised. While true that no user suffers broken behavior, a JSDoc that describes only half of a new feature's scope is a genuine documentation bug. The fix is a one-line word change and the cost of the inaccuracy (misleading developers) outweighs the cost of correcting it.

How to fix

Change "on the standalone GET SSE stream" to "on all GET-initiated SSE streams (both the initial standalone stream and reconnecting Last-Event-ID streams)". Update the changeset description similarly.

@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from 5fcb451 to d490dceCompareMay 22, 2026 13:43
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request May 22, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request May 22, 2026
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey

Copy link
Copy Markdown
Author

@felixweinberger How can I help get this PR merged?

rcdaileyand others added 6 commits July 18, 2026 09:08
Adds an opt-in keepAliveInterval option to
WebStandardStreamableHTTPServerTransportOptions that sends periodic
SSE comments (`: keepalive`) on the standalone GET SSE stream.
Reverse proxies commonly close connections that are idle for 30-60s.
With no server-initiated messages, the GET SSE stream has no traffic
during quiet periods, causing silent disconnections. This option lets
operators send harmless SSE comments at a configurable cadence to
keep the connection alive.
The timer is cleared on close(), closeStandaloneSSEStream(), and on
stream cancellation. Disabled by default; no behavior change for
existing deployments.
Addresses upstream modelcontextprotocol#28, modelcontextprotocol#876.
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
Prevent timer handle leaks when concurrent reconnect requests bypass
the conflict check. This edge case is possible when EventStore omits the
optional getStreamIdForEventId method, allowing duplicate replay
attempts to start new timers without cleaning up existing ones.
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from d490dce to be9c114CompareJuly 18, 2026 14:21
@rcdailey

Copy link
Copy Markdown
Author

@felixweinberger I've rebased onto main and resolved the conflicts with the upstream stale-cancel guards and replay event deduplication logic. Had to fix one interaction between the early-close block (for completed replay requests) and the keepalive timer in your per-stream ownership commit, plus update the concurrent-stream test to account for it.

The CI failure on the previous push was a flaky Cloudflare Workers miniflare test (cloudflareWorkers.test.ts > should handle MCP requests - "Network connection lost"), unrelated to this PR. Main's CI was green at the same time.

I want to be straightforward: this PR has been open since March, and I've rebased and addressed feedback multiple times over the past four months. I don't have the bandwidth to keep maintaining it indefinitely. If there's something blocking the merge, I'd appreciate knowing what it is. Otherwise, this will be my last update before I close the PR.

The early-close block for completed replay requests deletes the stream
mapping entry before the keepalive timer is started. When the client
subsequently closes the stream, the cancel callback's stale-guard check
(which gates timer cleanup on the mapping still pointing at this
controller) cannot find the mapping, leaving the timer running.
- Move clearInterval above the stale-guard check so it always fires for
the closure-local timer
- Guard keepalive timer startup with a mapping-presence check to skip
setup when early-close already removed the entry
- Simulate an in-flight request in the concurrent-stream test so the
replay stream stays open past the early-close block
Sign up for freeto 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.

3 participants

@rcdailey@felixweinberger@km-anthropic
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(server): add keepAliveInterval for standalone GET SSE stream - #1726

Open
rcdailey wants to merge 7 commits into
modelcontextprotocol:mainfrom
rcdailey:feat/sse-keepalive-interval
Open

feat(server): add keepAliveInterval for standalone GET SSE stream#1726
rcdailey wants to merge 7 commits into
modelcontextprotocol:mainfrom
rcdailey:feat/sse-keepalive-interval

Conversation

@rcdailey

Copy link
Copy Markdown

Summary

Adds an opt-in keepAliveInterval option to WebStandardStreamableHTTPServerTransportOptions. When set, the transport sends periodic SSE comments (: keepalive\n\n) on the standalone GET SSE stream to reset proxy idle timers and prevent silent disconnections.

SSE comments are ignored by spec-compliant clients but keep the connection alive through reverse proxies (nginx, Envoy, HAProxy, cloud load balancers) that enforce idle timeouts.

Usage

consttransport=newWebStandardStreamableHTTPServerTransport({sessionIdGenerator: ()=>crypto.randomUUID(),keepAliveInterval: 15_000// send keepalive every 15 seconds});

Omitting the option preserves existing behavior (no keepalive, fully backwards compatible).

What changed

  • keepAliveInterval?: number added to the options interface (milliseconds, disabled by default)
  • handleGetRequest starts a setInterval that enqueues : keepalive\n\n to the stream controller
  • Timer is cleared on client disconnect, close(), and closeStandaloneSSEStream()
  • Controller enqueue is guarded against closed/errored state
  • The NodeStreamableHTTPServerTransport wrapper picks this up automatically (it type-aliases and passes through the options)
  • Three new tests covering: comments sent when enabled, no comments when disabled, cleanup on close

Related issues

Ref #28 (server-side ping automation, P1)
Ref #876 (SSE connections drop after ~5 min idle behind proxies)

This is complementary to protocol-level ping approaches (like PR #1717). SSE comments operate at the transport layer and don't require JSON-RPC round-trips, making them a lightweight way to keep the connection alive independently of protocol-level health checks.

@rcdailey
rcdailey requested a review from a team as a code ownerMarch 21, 2026 18:02
@changeset-bot

changeset-botBot commented Mar 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 82ee1a6

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

This PR includes changesets to release 10 packages
NameType
@modelcontextprotocol/serverMinor
@modelcontextprotocol/expressMajor
@modelcontextprotocol/fastifyMajor
@modelcontextprotocol/honoMajor
@modelcontextprotocol/nodeMajor
@modelcontextprotocol/coreMinor
@modelcontextprotocol/clientMinor
@modelcontextprotocol/server-legacyMinor
@modelcontextprotocol/codemodMinor
@modelcontextprotocol/core-internalPatch

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

@pkg-pr-new

pkg-pr-newBot commented Mar 21, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@1726

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@1726

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@1726

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@1726

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@1726

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@1726

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@1726

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@1726

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@1726

commit: 82ee1a6

@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

@felixweinberger
felixweinbergerforce-pushed the feat/sse-keepalive-interval branch from 941226e to 5a925e1CompareMarch 31, 2026 10:31

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

Thanks for picking this up - I think this is a good direction.

Couple of inline comments.

Comment threadpackages/server/src/server/streamableHttp.ts
Comment threadpackages/server/test/server/streamableHttp.test.ts
@km-anthropic

Copy link
Copy Markdown

@claude review

@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from d29e61f to 6a2fd6fCompareMarch 31, 2026 23:39
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Mar 31, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/server/src/server/streamableHttp.ts
Comment threadpackages/server/src/server/streamableHttp.ts
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 1, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from d83942e to 8af2d12CompareApril 1, 2026 12:39
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/server/src/server/streamableHttp.ts
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from 8af2d12 to ccbc773CompareApril 2, 2026 01:09
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/server/src/server/streamableHttp.ts
@rcdailey

Copy link
Copy Markdown
Author

@felixweinberger The bot is identifying a real limitation (single shared _keepAliveTimer field with concurrent streams), but it's the same root cause from last round at a different angle. Properly fixing it means per-stream timer ownership, which is a bigger refactor beyond this PR's scope.

Could you take a look at the current state and let me know if this is mergeable, or if there's anything else you'd like changed?

@felixweinberger

Copy link
Copy Markdown
Contributor

@felixweinberger The bot is identifying a real limitation (single shared _keepAliveTimer field with concurrent streams), but it's the same root cause from last round at a different angle. Properly fixing it means per-stream timer ownership, which is a bigger refactor beyond this PR's scope.

Could you take a look at the current state and let me know if this is mergeable, or if there's anything else you'd like changed?

Yes will take a look thanks

rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from ccbc773 to 1a750b9CompareApril 2, 2026 13:13
@rcdailey

Copy link
Copy Markdown
Author

Thanks Felix. My latest push is a rebase to resolve conflicts with main; no additional changes were made.

@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

@felixweinberger

Copy link
Copy Markdown
Contributor

I think the bot comment is real, pushed a potential fix with an additional regression test

@claudeclaudeBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM — the per-stream keepAliveTimer ownership in StreamMapping correctly resolves the cross-stream timer interference I raised last round, and the new concurrent-stream regression test validates it.

Extended reasoning...

Overview

This PR adds an opt-in keepAliveInterval option to WebStandardStreamableHTTPServerTransport that sends periodic SSE comments to prevent reverse-proxy idle-timeout disconnections. Three files are modified: the changeset descriptor, streamableHttp.ts, and its test file.

Security risks

No security-sensitive code is touched. The feature adds outbound SSE comment writes guarded by a try/catch; there is no new input surface, no auth changes, and no data exposure risk.

Level of scrutiny

This is a new opt-in transport-layer feature that required several rounds of iteration. The logic is now self-contained in handleGetRequest and replayEvents, and the final design (per-stream ownership via StreamMapping.keepAliveTimer) is the correct approach for isolating concurrent stream lifetimes.

Other factors

After four rounds of review and fixes, the substantive bugs have all been addressed: replay-path keepalives were added, the closeStandaloneSSEStream guard was corrected, and the shared-timer cross-cancellation hazard was eliminated by moving to per-stream ownership. A dedicated regression test (should maintain independent keepalive timers per concurrent stream) directly exercises the concurrent scenario. The only open item is a JSDoc documentation nit (inline comment already filed) with no runtime impact. The feature is backwards-compatible and fully opt-in.

Comment on lines +146 to +153
/**
* Interval in milliseconds for sending SSE keepalive comments on the standalone
* GET SSE stream. When set, the transport sends periodic SSE comments
* (`: keepalive`) to prevent reverse proxies from closing idle connections.
*
* Disabled by default (no keepalive comments are sent).
*/
keepAliveInterval?: number;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The JSDoc for keepAliveInterval (line 147) says it sends keepalive comments on the standalone GET SSE stream, but the implementation also starts the timer for reconnecting clients via the Last-Event-ID / replayEvents() path. The description should be updated to reflect that the option applies to all GET-initiated SSE streams, not just the initial standalone one.

Extended reasoning...

What the inaccuracy is

The JSDoc for keepAliveInterval reads: "Interval in milliseconds for sending SSE keepalive comments on the standalone GET SSE stream." This is factually incomplete. The same PR also added keepalive timer support inside replayEvents() (the Last-Event-ID reconnect path), so keepAliveInterval governs keepalive behaviour on both GET stream types.

The specific code evidence

In handleGetRequest() (around line 500 of the diff), when this._keepAliveInterval !== undefined, a setInterval is stored in mapping.keepAliveTimer. In replayEvents() (around line 600), an identical block assigns a local keepAliveTimer and stores it in mapping.keepAliveTimer. Both paths read the same this._keepAliveInterval field. The changeset file .changeset/add-sse-keepalive.md repeats the same partial description: "sends periodic SSE comments on the standalone GET stream".

Why existing code does not prevent confusion

A developer reading only the JSDoc would conclude that reconnecting clients (those sending Last-Event-ID) do not benefit from the keepalive option, and might add their own keepalive logic elsewhere, leading to duplicate timers or misfiled bugs.

Impact

There is no runtime impact -- the code works correctly for both stream types and users actually get more coverage than advertised. The issue is purely documentation: a developer relying on the JSDoc would have an incomplete mental model of the feature scope.

Concrete proof

  1. Developer reads the JSDoc: "sends SSE keepalive comments on the standalone GET SSE stream."
  2. Developer sets keepAliveInterval: 15_000 intending to keep all GET-based connections alive.
  3. A client reconnects with Last-Event-ID -> enters replayEvents() -> timer is started there too.
  4. Developer is unaware replay streams also have keepalives, potentially adding redundant logic or assuming they are missing.

Addressing the refutation

One verifier argued this is not worth reporting because the behavior is more generous than advertised. While true that no user suffers broken behavior, a JSDoc that describes only half of a new feature's scope is a genuine documentation bug. The fix is a one-line word change and the cost of the inaccuracy (misleading developers) outweighs the cost of correcting it.

How to fix

Change "on the standalone GET SSE stream" to "on all GET-initiated SSE streams (both the initial standalone stream and reconnecting Last-Event-ID streams)". Update the changeset description similarly.

@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from 5fcb451 to d490dceCompareMay 22, 2026 13:43
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request May 22, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request May 22, 2026
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey

Copy link
Copy Markdown
Author

@felixweinberger How can I help get this PR merged?

rcdaileyand others added 6 commits July 18, 2026 09:08
Adds an opt-in keepAliveInterval option to
WebStandardStreamableHTTPServerTransportOptions that sends periodic
SSE comments (`: keepalive`) on the standalone GET SSE stream.
Reverse proxies commonly close connections that are idle for 30-60s.
With no server-initiated messages, the GET SSE stream has no traffic
during quiet periods, causing silent disconnections. This option lets
operators send harmless SSE comments at a configurable cadence to
keep the connection alive.
The timer is cleared on close(), closeStandaloneSSEStream(), and on
stream cancellation. Disabled by default; no behavior change for
existing deployments.
Addresses upstream modelcontextprotocol#28, modelcontextprotocol#876.
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
Prevent timer handle leaks when concurrent reconnect requests bypass
the conflict check. This edge case is possible when EventStore omits the
optional getStreamIdForEventId method, allowing duplicate replay
attempts to start new timers without cleaning up existing ones.
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from d490dce to be9c114CompareJuly 18, 2026 14:21
@rcdailey

Copy link
Copy Markdown
Author

@felixweinberger I've rebased onto main and resolved the conflicts with the upstream stale-cancel guards and replay event deduplication logic. Had to fix one interaction between the early-close block (for completed replay requests) and the keepalive timer in your per-stream ownership commit, plus update the concurrent-stream test to account for it.

The CI failure on the previous push was a flaky Cloudflare Workers miniflare test (cloudflareWorkers.test.ts > should handle MCP requests - "Network connection lost"), unrelated to this PR. Main's CI was green at the same time.

I want to be straightforward: this PR has been open since March, and I've rebased and addressed feedback multiple times over the past four months. I don't have the bandwidth to keep maintaining it indefinitely. If there's something blocking the merge, I'd appreciate knowing what it is. Otherwise, this will be my last update before I close the PR.

The early-close block for completed replay requests deletes the stream
mapping entry before the keepalive timer is started. When the client
subsequently closes the stream, the cancel callback's stale-guard check
(which gates timer cleanup on the mapping still pointing at this
controller) cannot find the mapping, leaving the timer running.
- Move clearInterval above the stale-guard check so it always fires for
the closure-local timer
- Guard keepalive timer startup with a mapping-presence check to skip
setup when early-close already removed the entry
- Simulate an in-flight request in the concurrent-stream test so the
replay stream stays open past the early-close block
Sign up for freeto 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.

3 participants

@rcdailey@felixweinberger@km-anthropic
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(server): add keepAliveInterval for standalone GET SSE stream - #1726

Open
rcdailey wants to merge 7 commits into
modelcontextprotocol:mainfrom
rcdailey:feat/sse-keepalive-interval
Open

feat(server): add keepAliveInterval for standalone GET SSE stream#1726
rcdailey wants to merge 7 commits into
modelcontextprotocol:mainfrom
rcdailey:feat/sse-keepalive-interval

Conversation

@rcdailey

Copy link
Copy Markdown

Summary

Adds an opt-in keepAliveInterval option to WebStandardStreamableHTTPServerTransportOptions. When set, the transport sends periodic SSE comments (: keepalive\n\n) on the standalone GET SSE stream to reset proxy idle timers and prevent silent disconnections.

SSE comments are ignored by spec-compliant clients but keep the connection alive through reverse proxies (nginx, Envoy, HAProxy, cloud load balancers) that enforce idle timeouts.

Usage

consttransport=newWebStandardStreamableHTTPServerTransport({sessionIdGenerator: ()=>crypto.randomUUID(),keepAliveInterval: 15_000// send keepalive every 15 seconds});

Omitting the option preserves existing behavior (no keepalive, fully backwards compatible).

What changed

  • keepAliveInterval?: number added to the options interface (milliseconds, disabled by default)
  • handleGetRequest starts a setInterval that enqueues : keepalive\n\n to the stream controller
  • Timer is cleared on client disconnect, close(), and closeStandaloneSSEStream()
  • Controller enqueue is guarded against closed/errored state
  • The NodeStreamableHTTPServerTransport wrapper picks this up automatically (it type-aliases and passes through the options)
  • Three new tests covering: comments sent when enabled, no comments when disabled, cleanup on close

Related issues

Ref #28 (server-side ping automation, P1)
Ref #876 (SSE connections drop after ~5 min idle behind proxies)

This is complementary to protocol-level ping approaches (like PR #1717). SSE comments operate at the transport layer and don't require JSON-RPC round-trips, making them a lightweight way to keep the connection alive independently of protocol-level health checks.

@rcdailey
rcdailey requested a review from a team as a code ownerMarch 21, 2026 18:02
@changeset-bot

changeset-botBot commented Mar 21, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 82ee1a6

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

This PR includes changesets to release 10 packages
NameType
@modelcontextprotocol/serverMinor
@modelcontextprotocol/expressMajor
@modelcontextprotocol/fastifyMajor
@modelcontextprotocol/honoMajor
@modelcontextprotocol/nodeMajor
@modelcontextprotocol/coreMinor
@modelcontextprotocol/clientMinor
@modelcontextprotocol/server-legacyMinor
@modelcontextprotocol/codemodMinor
@modelcontextprotocol/core-internalPatch

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

@pkg-pr-new

pkg-pr-newBot commented Mar 21, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@1726

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@1726

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@1726

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@1726

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@1726

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@1726

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@1726

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@1726

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@1726

commit: 82ee1a6

@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

@felixweinberger
felixweinbergerforce-pushed the feat/sse-keepalive-interval branch from 941226e to 5a925e1CompareMarch 31, 2026 10:31

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

Thanks for picking this up - I think this is a good direction.

Couple of inline comments.

Comment threadpackages/server/src/server/streamableHttp.ts
Comment threadpackages/server/test/server/streamableHttp.test.ts
@km-anthropic

Copy link
Copy Markdown

@claude review

@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from d29e61f to 6a2fd6fCompareMarch 31, 2026 23:39
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Mar 31, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/server/src/server/streamableHttp.ts
Comment threadpackages/server/src/server/streamableHttp.ts
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 1, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from d83942e to 8af2d12CompareApril 1, 2026 12:39
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/server/src/server/streamableHttp.ts
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from 8af2d12 to ccbc773CompareApril 2, 2026 01:09
@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

Comment threadpackages/server/src/server/streamableHttp.ts
@rcdailey

Copy link
Copy Markdown
Author

@felixweinberger The bot is identifying a real limitation (single shared _keepAliveTimer field with concurrent streams), but it's the same root cause from last round at a different angle. Properly fixing it means per-stream timer ownership, which is a bigger refactor beyond this PR's scope.

Could you take a look at the current state and let me know if this is mergeable, or if there's anything else you'd like changed?

@felixweinberger

Copy link
Copy Markdown
Contributor

@felixweinberger The bot is identifying a real limitation (single shared _keepAliveTimer field with concurrent streams), but it's the same root cause from last round at a different angle. Properly fixing it means per-stream timer ownership, which is a bigger refactor beyond this PR's scope.

Could you take a look at the current state and let me know if this is mergeable, or if there's anything else you'd like changed?

Yes will take a look thanks

rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request Apr 2, 2026
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from ccbc773 to 1a750b9CompareApril 2, 2026 13:13
@rcdailey

Copy link
Copy Markdown
Author

Thanks Felix. My latest push is a rebase to resolve conflicts with main; no additional changes were made.

@felixweinberger

Copy link
Copy Markdown
Contributor

@claude review

@felixweinberger

Copy link
Copy Markdown
Contributor

I think the bot comment is real, pushed a potential fix with an additional regression test

@claudeclaudeBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM — the per-stream keepAliveTimer ownership in StreamMapping correctly resolves the cross-stream timer interference I raised last round, and the new concurrent-stream regression test validates it.

Extended reasoning...

Overview

This PR adds an opt-in keepAliveInterval option to WebStandardStreamableHTTPServerTransport that sends periodic SSE comments to prevent reverse-proxy idle-timeout disconnections. Three files are modified: the changeset descriptor, streamableHttp.ts, and its test file.

Security risks

No security-sensitive code is touched. The feature adds outbound SSE comment writes guarded by a try/catch; there is no new input surface, no auth changes, and no data exposure risk.

Level of scrutiny

This is a new opt-in transport-layer feature that required several rounds of iteration. The logic is now self-contained in handleGetRequest and replayEvents, and the final design (per-stream ownership via StreamMapping.keepAliveTimer) is the correct approach for isolating concurrent stream lifetimes.

Other factors

After four rounds of review and fixes, the substantive bugs have all been addressed: replay-path keepalives were added, the closeStandaloneSSEStream guard was corrected, and the shared-timer cross-cancellation hazard was eliminated by moving to per-stream ownership. A dedicated regression test (should maintain independent keepalive timers per concurrent stream) directly exercises the concurrent scenario. The only open item is a JSDoc documentation nit (inline comment already filed) with no runtime impact. The feature is backwards-compatible and fully opt-in.

Comment on lines +146 to +153
/**
* Interval in milliseconds for sending SSE keepalive comments on the standalone
* GET SSE stream. When set, the transport sends periodic SSE comments
* (`: keepalive`) to prevent reverse proxies from closing idle connections.
*
* Disabled by default (no keepalive comments are sent).
*/
keepAliveInterval?: number;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 The JSDoc for keepAliveInterval (line 147) says it sends keepalive comments on the standalone GET SSE stream, but the implementation also starts the timer for reconnecting clients via the Last-Event-ID / replayEvents() path. The description should be updated to reflect that the option applies to all GET-initiated SSE streams, not just the initial standalone one.

Extended reasoning...

What the inaccuracy is

The JSDoc for keepAliveInterval reads: "Interval in milliseconds for sending SSE keepalive comments on the standalone GET SSE stream." This is factually incomplete. The same PR also added keepalive timer support inside replayEvents() (the Last-Event-ID reconnect path), so keepAliveInterval governs keepalive behaviour on both GET stream types.

The specific code evidence

In handleGetRequest() (around line 500 of the diff), when this._keepAliveInterval !== undefined, a setInterval is stored in mapping.keepAliveTimer. In replayEvents() (around line 600), an identical block assigns a local keepAliveTimer and stores it in mapping.keepAliveTimer. Both paths read the same this._keepAliveInterval field. The changeset file .changeset/add-sse-keepalive.md repeats the same partial description: "sends periodic SSE comments on the standalone GET stream".

Why existing code does not prevent confusion

A developer reading only the JSDoc would conclude that reconnecting clients (those sending Last-Event-ID) do not benefit from the keepalive option, and might add their own keepalive logic elsewhere, leading to duplicate timers or misfiled bugs.

Impact

There is no runtime impact -- the code works correctly for both stream types and users actually get more coverage than advertised. The issue is purely documentation: a developer relying on the JSDoc would have an incomplete mental model of the feature scope.

Concrete proof

  1. Developer reads the JSDoc: "sends SSE keepalive comments on the standalone GET SSE stream."
  2. Developer sets keepAliveInterval: 15_000 intending to keep all GET-based connections alive.
  3. A client reconnects with Last-Event-ID -> enters replayEvents() -> timer is started there too.
  4. Developer is unaware replay streams also have keepalives, potentially adding redundant logic or assuming they are missing.

Addressing the refutation

One verifier argued this is not worth reporting because the behavior is more generous than advertised. While true that no user suffers broken behavior, a JSDoc that describes only half of a new feature's scope is a genuine documentation bug. The fix is a one-line word change and the cost of the inaccuracy (misleading developers) outweighs the cost of correcting it.

How to fix

Change "on the standalone GET SSE stream" to "on all GET-initiated SSE streams (both the initial standalone stream and reconnecting Last-Event-ID streams)". Update the changeset description similarly.

@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from 5fcb451 to d490dceCompareMay 22, 2026 13:43
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request May 22, 2026
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
rcdailey added a commit to rcdailey/typescript-sdk that referenced this pull request May 22, 2026
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey

Copy link
Copy Markdown
Author

@felixweinberger How can I help get this PR merged?

rcdaileyand others added 6 commits July 18, 2026 09:08
Adds an opt-in keepAliveInterval option to
WebStandardStreamableHTTPServerTransportOptions that sends periodic
SSE comments (`: keepalive`) on the standalone GET SSE stream.
Reverse proxies commonly close connections that are idle for 30-60s.
With no server-initiated messages, the GET SSE stream has no traffic
during quiet periods, causing silent disconnections. This option lets
operators send harmless SSE comments at a configurable cadence to
keep the connection alive.
The timer is cleared on close(), closeStandaloneSSEStream(), and on
stream cancellation. Disabled by default; no behavior change for
existing deployments.
Addresses upstream modelcontextprotocol#28, modelcontextprotocol#876.
The replayEvents() code path (client reconnects with Last-Event-ID) was
missing keepalive timer setup, so reconnecting clients would lose
keepalive protection and get dropped again at the next proxy idle
timeout.
Also fixes the cleanup test to actually prove that close() clears the
timer by asserting vi.getTimerCount() drops to 0, instead of relying
on the catch fallback which would self-clear anyway.
Addresses PR review feedback from @felixweinberger on PR modelcontextprotocol#1726.
Prevent timer handle leaks when concurrent reconnect requests bypass
the conflict check. This edge case is possible when EventStore omits the
optional getStreamIdForEventId method, allowing duplicate replay
attempts to start new timers without cleaning up existing ones.
Adds defensive _clearKeepAliveTimer() before setInterval in
handleGetRequest() to prevent duplicate timers when the GET stream is
reinitialized.
Moves _clearKeepAliveTimer() inside the if(stream) guard in
closeStandaloneSSEStream() so it only clears when the standalone stream
is being torn down, preventing accidental clearing of a replay stream's
timer.
Closesmodelcontextprotocol#1726
@rcdailey
rcdaileyforce-pushed the feat/sse-keepalive-interval branch from d490dce to be9c114CompareJuly 18, 2026 14:21
@rcdailey

Copy link
Copy Markdown
Author

@felixweinberger I've rebased onto main and resolved the conflicts with the upstream stale-cancel guards and replay event deduplication logic. Had to fix one interaction between the early-close block (for completed replay requests) and the keepalive timer in your per-stream ownership commit, plus update the concurrent-stream test to account for it.

The CI failure on the previous push was a flaky Cloudflare Workers miniflare test (cloudflareWorkers.test.ts > should handle MCP requests - "Network connection lost"), unrelated to this PR. Main's CI was green at the same time.

I want to be straightforward: this PR has been open since March, and I've rebased and addressed feedback multiple times over the past four months. I don't have the bandwidth to keep maintaining it indefinitely. If there's something blocking the merge, I'd appreciate knowing what it is. Otherwise, this will be my last update before I close the PR.

The early-close block for completed replay requests deletes the stream
mapping entry before the keepalive timer is started. When the client
subsequently closes the stream, the cancel callback's stale-guard check
(which gates timer cleanup on the mapping still pointing at this
controller) cannot find the mapping, leaving the timer running.
- Move clearInterval above the stale-guard check so it always fires for
the closure-local timer
- Guard keepalive timer startup with a mapping-presence check to skip
setup when early-close already removed the entry
- Simulate an in-flight request in the concurrent-stream test so the
replay stream stays open past the early-close block
Sign up for freeto 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.

3 participants

@rcdailey@felixweinberger@km-anthropic