Skip to content

fix(protocol): distinguish AbortSignal cancellation from request timeout - #2617

Open
xudongWu2022 wants to merge 1 commit into
modelcontextprotocol:mainfrom
xudongWu2022:fix/2165-request-aborted-error-code
Open

fix(protocol): distinguish AbortSignal cancellation from request timeout#2617
xudongWu2022 wants to merge 1 commit into
modelcontextprotocol:mainfrom
xudongWu2022:fix/2165-request-aborted-error-code

Conversation

@xudongWu2022

Copy link
Copy Markdown

Aborting a request through RequestOptions.signal rejected with SdkErrorCode.RequestTimeout — the same code an elapsed RequestOptions.timeout produces — so callers had no way to tell a deliberate cancellation apart from a real timeout.

Fixes #2165

Motivation and Context

The cancel() fallback in Protocol._requestWithSchemaViaCodec() wrapped any non-SdkError abort reason as SdkErrorCode.RequestTimeout, and onAbort = () => cancel(options?.signal?.reason) feeds AbortSignal.reason straight into it. Retry/backoff logic gated on RequestTimeout therefore fired on every user cancellation:

if (error.code === SdkErrorCode.RequestTimeout) {
    showRetryMessage(); // also fired on controller.abort()
}

This adds SdkErrorCode.RequestAborted and moves all four non-SdkError wrap sites onto it together, per the scope note in the triage comment on #2165:

Site File
Protocol.request() in-flight abort (cancel() fallback) packages/core-internal/src/shared/protocol.ts
Protocol.request() pre-aborted signal check packages/core-internal/src/shared/protocol.ts
Client._serveFromCache() warm-hit pre-abort guard packages/client/src/client/client.ts
Client.listen() pre-abort guard packages/client/src/client/client.ts

Moving them together is the point: leaving the cache and listen() sites on RequestTimeout would make the same abort surface as two different codes depending on whether the cache happened to be warm.

The timeout path is untouched. timeoutHandler constructs a typed SdkError(RequestTimeout, 'Request timed out', { timeout }), which reaches cancel() as an SdkError and passes through the wrap via the instanceof SdkError branch. An abort reason that is already an SdkError is likewise still rethrown verbatim with its own code.

Relationship to #2177

@sakthiveltofficial opened #2177 for this issue first and landed the enum member plus the cancel() fallback — credit for the diagnosis and the first fix is theirs. That PR predates the core-internal package split, so it now conflicts with main, and it covers one of the four wrap sites. This PR rebases the same idea onto current main and completes it: the remaining three sites, the two behavior pins and the enum ABI pin that lock the old contract, the migration-guide entries, and a changeset. Happy to close this in favour of an updated #2177 if @sakthiveltofficial would rather carry it — no ownership claim here, the issue had just been idle for a while.

How Has This Been Tested?

New coverage in packages/core-internal/test/shared/abortReasonPassthrough.test.ts:

  • in-flight abort(new DOMException('User cancelled', 'AbortError')) against a timeout: 60_000 request → RequestAborted, explicitly asserted not RequestTimeout, reason text preserved
  • in-flight bare abort() with no reason → RequestAborted
  • timeout: 0 control → still RequestTimeout

Updated pins that locked the old wrap-as-RequestTimeout contract:

  • core-internal/test/shared/abortReasonPassthrough.test.ts — non-SdkError reason pin
  • core-internal/test/types/errorSurfacePins.test.tsSdkErrorCode enum-membership ABI pin
  • client/test/client/responseCache.test.ts — warm-hit pre-abort
  • client/test/client/listen.test.tslisten() pre-abort (the ack-timeout test in the same file correctly stays on RequestTimeout)

Run locally:

pnpm -r --no-bail test    core-internal 1436 ✓ · client 797 ✓ · server 42 files ✓ ·
                          server-legacy 11 ✓ · middleware ✓ · integration 19 ✓ · e2e 2635 ✓
pnpm typecheck:all        ✓
pnpm lint:all             ✓ (incl. prettier + sync:snippets --check)

Three suites fail on my Windows box for environment reasons and fail identically on an unmodified main (verified by stashing): the codemod symlink-cycle test (EPERM — Windows needs elevation to create symlinks), and two scenarios/stdio.test.ts shutdown-escalation cases (no POSIX signal escalation on Windows). pnpm docs:check also can't run on Windows — typedoc rejects \ path separators.

Breaking Changes

Behavioral, and deliberately so — it is the fix. Code that reached the catch on a user cancellation and compared against SdkErrorCode.RequestTimeout now sees RequestAborted. Anything checking signal.aborted first, or comparing against RequestTimeout only for genuine timeouts, is unaffected. RequestAborted is a new enum member, so nothing existing can be reading it yet.

Changeset is minor for core-internal, client, and server (the enum is re-exported through core-internal/public onto both package roots). Migration guide updated in docs/migration/upgrade-to-v2.md: the SdkErrorCode table gains a row, and the two already-aborted-signal entries now name RequestAborted.

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

Two follow-ups, deliberately kept out of this PR to keep it reviewable — happy to take either on:

  1. v1.x backport. The triage comment notes v1.x carries the same shape with McpError / ErrorCode.RequestTimeout at src/shared/protocol.ts:1185. I'd rather send that as a separate PR against v1.x than mix branches here.
  2. inputRequiredDriver.ts. packages/core-internal/src/shared/inputRequiredDriver.ts:163 and :172 wrap a signal.reason with the identical pattern. They are outside the four sites the triage comment scoped, so I left them alone rather than widen the diff — but if you consider them part of the same contract, say the word and I'll fold them in (they'd need their pins in inputRequiredDriver.test.ts updated too).

Aborting a request through `RequestOptions.signal` rejected with
`SdkErrorCode.RequestTimeout` — the same code an elapsed
`RequestOptions.timeout` produces — so callers could not tell a deliberate
cancellation apart from a real timeout, and retry/backoff logic gated on
`RequestTimeout` fired on every user cancellation.

Add `SdkErrorCode.RequestAborted` and move all four non-`SdkError` wrap
sites onto it together, so the reported code no longer depends on where the
abort lands:

- `Protocol.request()` in-flight abort (the `cancel()` fallback)
- `Protocol.request()` pre-aborted signal check
- `Client._serveFromCache()` warm-hit pre-abort guard
- `Client.listen()` pre-abort guard

The timeout path is untouched: the timeout handler constructs a typed
`SdkError(RequestTimeout, 'Request timed out')` which reaches `cancel()` as
an `SdkError` and passes through the wrap unmodified. An abort `reason` that
is already an `SdkError` is likewise still rethrown verbatim with its own
code.

Fixes modelcontextprotocol#2165
@xudongWu2022
xudongWu2022 requested a review from a team as a code owner August 6, 2026 06:10
@changeset-bot

changeset-bot Bot commented Aug 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c3b7441

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

This PR includes changesets to release 10 packages
Name Type
@modelcontextprotocol/core-internal Minor
@modelcontextprotocol/client Minor
@modelcontextprotocol/server Minor
@modelcontextprotocol/express Major
@modelcontextprotocol/fastify Major
@modelcontextprotocol/hono Major
@modelcontextprotocol/node Major
@modelcontextprotocol/core Minor
@modelcontextprotocol/server-legacy Minor
@modelcontextprotocol/codemod 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

pkg-pr-new Bot commented Aug 6, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

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

@modelcontextprotocol/codemod

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

@modelcontextprotocol/core

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

@modelcontextprotocol/server

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

@modelcontextprotocol/server-legacy

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

@modelcontextprotocol/express

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

@modelcontextprotocol/fastify

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

@modelcontextprotocol/hono

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

@modelcontextprotocol/node

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

commit: c3b7441

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.

AbortSignal cancellation throws SdkErrorCode.RequestTimeout instead of a distinct abort error

1 participant