Skip to content

fix(client): opt-in graceful close drains in-flight requests before transport teardown - #2750

Open
K4bain wants to merge 2 commits into
modelcontextprotocol:mainfrom
K4bain:fix/graceful-close-drain
Open

fix(client): opt-in graceful close drains in-flight requests before transport teardown#2750
K4bain wants to merge 2 commits into
modelcontextprotocol:mainfrom
K4bain:fix/graceful-close-drain

Conversation

@K4bain

@K4bain K4bain commented Sep 2, 2026

Copy link
Copy Markdown

Closes #1231.

What

Opt-in graceful close at the Client.close() level — the exact direction proposed by @felixweinberger on #1692 ("I'd rather see that as an opt-in on Client.close() (which already has the pending-request registry in _responseHandlers) than a mandatory transport-level drain"):

// per call
await client.close({ drainPendingRequests: true });          // default 2s cap
await client.close({ drainPendingRequests: { timeoutMs: 10_000 } });

// or once at construction (SIGINT-style shutdowns where you don't know what's in flight)
const client = new Client(info, { gracefulClose: true });
await client.close();

Why

Without draining, transport teardown aborts in-flight HTTP requests that the server may have already answered. OpenTelemetry's undici instrumentation then reports those as UND_ERR_ABORTED — failed spans on 200 OK responses — the symptom in #1231. Draining lets the response finish reading so telemetry reflects the real outcome.

This deliberately differs from the closed attempts #1569/#1692 (mandatory transport-level drain) and composes with the open #2028 (per-request controller decoupling at the transport layer) — the two are complementary, not competing.

How

  • Protocol (core-internal) tracks pending outbound request ids in a _pendingRequestIds set alongside the existing response-handler lifecycle: registered before the send so a concurrent close sees the request, released on every exit path (response, timeout, cancel, caller abort, close) via the request funnel's existing finally cleanup.
  • Protocol.close(options) gains drainPendingRequests: waits for the pending set to empty (event-driven wake, never rejects), then closes the transport. Requests still outstanding after the drain timeout fall through to the normal close path and settle with the existing connection-closed error — stuck-request cleanup is preserved exactly as before.
  • Client.close(ClientCloseOptions) resolves precedence: explicit argument > ClientOptions.gracefulClose constructor default > immediate close (unchanged historical behavior).
  • subscriptions/listen sends directly on the transport and does not enter the pending set, so long-lived subscriptions never stall a drain.
  • _onclose clears the pending set, so a drain started before an unexpected transport drop resolves immediately rather than waiting out its timeout.

Tests

7 new regression tests (gracefulClose.test.ts): default close unchanged (in-flight request still settles with connection-closed), drain-then-close ordering (transport provably still open while the request is outstanding), multiple in-flight requests (close waits for the last one), drain-timeout fallback, immediate close when idle, constructor default applying to parameterless close(), and explicit false overriding the constructor default.

Verification: client suite 881/881 green; core-internal suite green except the 2 pre-existing schemaTwinConformance byte-identity failures that also fail on clean main; typecheck green; prettier clean on all touched files.

A changeset is included (@modelcontextprotocol/client + @modelcontextprotocol/core-internal patch).

…ransport teardown

close({ drainPendingRequests: true }) waits for in-flight requests to
settle before the transport closes. Without it, transport teardown
aborts in-flight HTTP requests the server had already answered, which
OpenTelemetry's undici instrumentation reports as UND_ERR_ABORTED on
200 OK responses (modelcontextprotocol#1231).

- Protocol tracks pending request ids alongside the response-handler
  lifecycle and drains them before transport close when opted in
- Client.close({ drainPendingRequests }) and a ClientOptions.gracefulClose
  constructor default expose the behavior; an explicit argument wins
- Requests outstanding after the drain timeout (default 2s) settle via
  the normal close path; default close() behavior is unchanged
@K4bain
K4bain requested a review from a team as a code owner September 2, 2026 21:29
@changeset-bot

changeset-bot Bot commented Sep 2, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7e9700a

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

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/client Patch
@modelcontextprotocol/core-internal Patch
@modelcontextprotocol/codemod Patch
@modelcontextprotocol/core Patch
@modelcontextprotocol/server-legacy Patch
@modelcontextprotocol/server Patch

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

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

@pkg-pr-new

pkg-pr-new Bot commented Sep 2, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/codemod

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

@modelcontextprotocol/core

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

@modelcontextprotocol/server

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

@modelcontextprotocol/server-legacy

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: 7e9700a

The open brace in {@linkcode Protocol.close | close({ drainPendingRequests })}
is flagged by typedoc ('Encountered an open brace within an inline tag'),
which fails the docs:check CI gate. Use plain backticks for the call form.
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.

Undici instrumentation marks successful MCP requests as aborted due to premature AbortController.abort() in SSE / StreamableHTTP transports

1 participant