Skip to content

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

Description

@sakthiveltofficial

Describe the bug
When you cancel a request using AbortSignal, the error you get back has code: 'REQUEST_TIMEOUT' — the same code as an actual timeout. There's no way to tell them apart.

I had logic like this in my app:

if (error.code === SdkErrorCode.RequestTimeout) {
  showRetryMessage(); // was also firing on manual user cancellation
}

Both a real timeout and a deliberate controller.abort() hit the same fallback in cancel() inside protocol.ts (line ~910), which unconditionally wraps any non-SdkError reason as SdkErrorCode.RequestTimeout.

To Reproduce

import { Client } from '@modelcontextprotocol/client';
import { Server } from '@modelcontextprotocol/server';
import { InMemoryTransport, SdkError, SdkErrorCode } from '@modelcontextprotocol/core';

const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const server = new Server({ name: 'test-server', version: '1.0' });
const client = new Client({ name: 'test-client', version: '1.0' });

// handler that hangs until cancelled
server.setRequestHandler('ping', async (_req, ctx) => {
  await new Promise<void>((_, reject) => {
    ctx.mcpReq.signal.addEventListener('abort', () => reject(new Error('cancelled')));
  });
  return {};
});

await server.connect(serverTransport);
await client.connect(clientTransport);

const controller = new AbortController();

// abort at 30ms — timeout is 60s, so it is definitely not the timeout firing
setTimeout(() => controller.abort(new DOMException('User cancelled', 'AbortError')), 30);

try {
  await client.request({ method: 'ping' }, { signal: controller.signal, timeout: 60_000 });
} catch (error) {
  if (error instanceof SdkError) {
    console.log(error.code); // REQUEST_TIMEOUT ← should be something like REQUEST_ABORTED
  }
}

Or run the repro test directly:

pnpm --filter "@modelcontextprotocol/test-integration" test -- test/bug-repros/bug1-abort-signal.test.ts

Expected behavior
Aborting via AbortSignal should produce a distinct error code (e.g. REQUEST_ABORTED) so callers can tell it apart from a real timeout. The timeout path already explicitly passes a typed SdkError — only the abort path falls through to the generic RequestTimeout fallback.

Logs

Caught error code: "REQUEST_TIMEOUT"
Caught error msg : "AbortError: User cancelled the request"

FAIL  test/bug-repros/bug1-abort-signal.test.ts
AssertionError:
  expected 'REQUEST_TIMEOUT' not to be 'REQUEST_TIMEOUT'

Additional context

Root cause is in packages/core/src/shared/protocol.ts around line 910:

const cancel = (reason: unknown) => {
  const error = reason instanceof SdkError
    ? reason
    : new SdkError(SdkErrorCode.RequestTimeout, String(reason)); // ← always RequestTimeout
  reject(error);
};

Quick fix would be adding RequestAborted = 'REQUEST_ABORTED' to SdkErrorCode and checking the reason type in cancel():

let error: SdkError;
if (reason instanceof SdkError) {
  error = reason;
} else if (reason instanceof DOMException && reason.name === 'AbortError') {
  error = new SdkError(SdkErrorCode.RequestAborted, reason.message);
} else {
  error = new SdkError(SdkErrorCode.RequestAborted, String(reason));
}
reject(error);

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Moderate issues affecting some users, edge cases, potentially valuable featurebugSomething isn't workingfix proposedBot has a verified fix diff in the commentgood first issueGood for newcomers - can be tackled without deep knowledge of the codebase

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions