modelcontextprotocol / modelcontextprotocol/typescript-sdk

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

Open
#2,165 4 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug fix proposed good first issue P2
Dominant language
TypeScript
Stars
13.4k
Forks
2.2k
Avg merge
3d 15h
Merged PRs (30d)
4

Description

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);

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in packages/core/src/shared/protocol.ts around line 910, focusing on the cancel() fallback and the SdkErrorCode definition. Run the repro with pnpm --filter "@modelcontextprotocol/test-integration" test -- test/bug-repros/bug1-abort-signal.test.ts. Done means AbortSignal cancellation produces a distinct abort error code while the existing timeout path remains REQUEST_TIMEOUT.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
75/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.