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);
Describe the bug
When you cancel a request using
AbortSignal, the error you get back hascode: '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:
Both a real timeout and a deliberate
controller.abort()hit the same fallback incancel()insideprotocol.ts(line ~910), which unconditionally wraps any non-SdkErrorreason asSdkErrorCode.RequestTimeout.To Reproduce
Or run the repro test directly:
Expected behavior
Aborting via
AbortSignalshould 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 typedSdkError— only the abort path falls through to the genericRequestTimeoutfallback.Logs
Additional context
Root cause is in
packages/core/src/shared/protocol.tsaround line 910:Quick fix would be adding
RequestAborted = 'REQUEST_ABORTED'toSdkErrorCodeand checking the reason type incancel():