Skip to content

feat(client): add reconnectionScheduler to StreamableHTTPClientTransport#1763

Open
felixweinberger wants to merge 1 commit intomainfrom
fweinberger/reconnection-scheduler
Open

feat(client): add reconnectionScheduler to StreamableHTTPClientTransport#1763
felixweinberger wants to merge 1 commit intomainfrom
fweinberger/reconnection-scheduler

Conversation

@felixweinberger
Copy link
Contributor

Adds a ReconnectionScheduler callback option to StreamableHTTPClientTransportOptions so non-persistent environments can override the default setTimeout-based SSE reconnection scheduling.

Motivation and Context

Fixes #1162. The current _scheduleReconnection uses setTimeout, which doesn't work well for:

  • Serverless/edge functions that terminate before the timer fires
  • Mobile apps that need platform-specific background scheduling (iOS Background Fetch, Android WorkManager)
  • Desktop apps handling sleep/wake cycles

Supersedes #1177 with one API addition: the scheduler may return a cancel function that is invoked on transport.close(), so pending custom-scheduled reconnections can be aborted the same way the built-in setTimeout is cleared. Thanks @CHOIJEWON for the original implementation.

How Has This Been Tested?

6 new tests in packages/client/test/client/streamableHttp.test.ts:

  • scheduler invoked with (reconnect, delay, attemptCount)
  • falls back to setTimeout when no scheduler provided
  • setTimeout not used when scheduler provided
  • returned cancel function called on close()
  • tolerates schedulers returning void (no cancel)
  • default setTimeout still cleared on close()

pnpm --filter @modelcontextprotocol/client test passes (317 tests).

Breaking Changes

None. New optional option, default behavior unchanged. Internal _reconnectionTimeout field renamed to _cancelReconnection (private, not part of public API).

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

API:

type ReconnectionScheduler = (
    reconnect: () => void,
    delay: number,
    attemptCount: number
) => (() => void) | void;

The _reconnectionTimeout?: ReturnType<typeof setTimeout> field is replaced by _cancelReconnection?: () => void, unifying cleanup for both the default and custom scheduler paths.

Adds a ReconnectionScheduler callback option so non-persistent
environments can override the default setTimeout-based SSE reconnection
scheduling.

The scheduler receives (reconnect, delay, attemptCount) and may return
a cancel function that is invoked on transport.close(). This ensures
pending custom-scheduled reconnections can be aborted the same way the
built-in setTimeout is cleared.

Replaces the _reconnectionTimeout field with a unified _cancelReconnection
callback that works for both the default and custom scheduler paths.

Fixes #1162
Closes #1177

Co-authored-by: CHOIJEWON <alsrn6040@kakao.com>
@felixweinberger felixweinberger requested a review from a team as a code owner March 25, 2026 21:26
@changeset-bot
Copy link

changeset-bot bot commented Mar 25, 2026

🦋 Changeset detected

Latest commit: 2d1622d

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

This PR includes changesets to release 1 package
Name Type
@modelcontextprotocol/client Minor

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
Copy link

pkg-pr-new bot commented Mar 25, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@1763

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@1763

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@1763

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@1763

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@1763

commit: 2d1622d

Copy link

@claude claude bot left a comment

Choose a reason for hiding this comment

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

LGTM — clean, minimal API addition with good test coverage and no change to default behavior.

Extended reasoning...

Overview

This PR adds a single optional reconnectionScheduler callback to StreamableHTTPClientTransportOptions, allowing non-persistent environments (serverless, mobile, desktop sleep/wake) to override the default setTimeout-based SSE reconnection. The implementation touches one source file (streamableHttp.ts) and its test file, plus a changeset. The internal _reconnectionTimeout field is replaced by a unified _cancelReconnection callback that works for both the default setTimeout path and custom schedulers.

Security risks

None. The change is a scheduling mechanism for reconnection — it does not touch auth, credentials, permissions, or data handling. The scheduler callback receives only a reconnect function, a delay number, and an attempt count.

Level of scrutiny

Low-to-moderate. This is a new optional configuration option with no change to default behavior. The API surface is small (ReconnectionScheduler type + one optional property). The refactoring from _reconnectionTimeout to _cancelReconnection is purely internal (private fields). The implementation correctly handles void returns from the scheduler, clears _cancelReconnection before invoking reconnect(), and the close() method properly calls and nullifies the cancel function.

Other factors

  • 6 new focused tests cover: scheduler invocation with correct arguments, setTimeout fallback, cancel-on-close, void-tolerant schedulers, and clearTimeout on close without a scheduler.
  • 2 existing tests were updated to reference the renamed private field (_cancelReconnection instead of _reconnectionTimeout).
  • No CODEOWNERS-specific paths are affected beyond the default wildcard.
  • The changeset correctly marks this as a minor version bump.
  • No outstanding reviewer comments or unaddressed feedback in the timeline.

@felixweinberger
Copy link
Contributor Author

Picked this up as something we discussed previously @mattzcarey

Copy link
Contributor

@bhosmer-ant bhosmer-ant left a comment

Choose a reason for hiding this comment

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

Nice feature — the API design is clean (passing the computed delay as a suggestion is a good call) and the _cancelReconnection abstraction is cleaner than the old timeout field. A few things to tighten up around the weaker cancellation contract that a user-supplied scheduler introduces:

Also: ReconnectionScheduler isn't re-exported from packages/client/src/index.ts — users can't import the type to annotate their scheduler implementation without reaching into the internal path.

this._reconnectionTimeout = setTimeout(() => {
// Use the last event ID to resume where we left off
const reconnect = (): void => {
this._cancelReconnection = undefined;
Copy link
Contributor

Choose a reason for hiding this comment

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

The reconnect closure should guard against the transport already being closed:

const reconnect = (): void => {
    this._cancelReconnection = undefined;
    if (this._abortController?.signal.aborted) return;  // ← add this
    this._startOrAuthSse(options).catch(...);
};

With setTimeout, clearTimeout is a hard guarantee — the callback won't fire after clear. A user-supplied cancel function has no such guarantee (or may return void, meaning no cancel at all). A late-firing reconnect would call _startOrAuthSse with an aborted signal → rejects → .catch schedules another reconnection, emitting spurious onerror("Failed to reconnect SSE stream") events on a closed transport (bounded by maxRetries, but noisy).

The existing _handleSseStream call sites already have this guard (lines ~432, ~451) — just need it here too.

clearTimeout(this._reconnectionTimeout);
this._reconnectionTimeout = undefined;
if (this._cancelReconnection) {
this._cancelReconnection();
Copy link
Contributor

Choose a reason for hiding this comment

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

If the user-provided cancel function throws, _abortController.abort() and onclose never fire — the transport stays half-open. Worth wrapping in try/finally (same theme as the try/finally additions in #1735):

try {
    this._cancelReconnection?.();
} finally {
    this._cancelReconnection = undefined;
    this._abortController?.abort();
    this.onclose?.();
}

this._startOrAuthSse(options).catch(error => {
this.onerror?.(new Error(`Failed to reconnect SSE stream: ${error instanceof Error ? error.message : String(error)}`));
// Schedule another attempt if this one failed, incrementing the attempt counter
this._scheduleReconnection(options, attemptCount + 1);
Copy link
Contributor

Choose a reason for hiding this comment

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

If _scheduleReconnection throws here (e.g., the user's scheduler throws), it's inside a .catch handler of a fire-and-forget promise chain — it becomes an unhandled rejection. Worth a try/catch that routes to onerror:

this._startOrAuthSse(options).catch(error => {
    this.onerror?.(new Error(`Failed to reconnect SSE stream: ...`));
    try {
        this._scheduleReconnection(options, attemptCount + 1);
    } catch (scheduleError) {
        this.onerror?.(scheduleError instanceof Error ? scheduleError : new Error(String(scheduleError)));
    }
});

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow customizable reconnection behavior for non-persistent clients

2 participants