From 18ea77a9b693ad502c0c02f21651dcca14eaab7d Mon Sep 17 00:00:00 2001 From: xiangnuans Date: Tue, 18 Aug 2026 11:22:25 +0800 Subject: [PATCH] fix(core): forward `cause` from the SdkError data slot to Error.cause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `classifyNetworkError` (and other call sites) pass an underlying error to `SdkError` as `{ cause }` in the `data` slot, but the constructor called `super(message)` with no options — so `Error.cause` stayed `undefined` and the standard cause chain broke. Loggers/trackers that walk `.cause` (pino's default serializer, Sentry) stopped at the `SdkError` and lost the root network error, so `ENOTFOUND` / `ECONNREFUSED` / `ETIMEDOUT` all rendered identically as "fetch failed". Forward `cause` to `super()` when `data` carries one, while still retaining the full object on `this.data`. This covers every call site that hands `{ cause }` to the data slot (the issue's preferred fix), and leaves `SdkHttpError`'s status/statusText payload untouched. Closes #2657 --- .../core-internal/src/errors/sdkErrors.ts | 15 +++++- .../test/errors/sdkErrorCause.test.ts | 48 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 packages/core-internal/test/errors/sdkErrorCause.test.ts diff --git a/packages/core-internal/src/errors/sdkErrors.ts b/packages/core-internal/src/errors/sdkErrors.ts index 0bc8f9a1ad..e529fd39d0 100644 --- a/packages/core-internal/src/errors/sdkErrors.ts +++ b/packages/core-internal/src/errors/sdkErrors.ts @@ -149,12 +149,25 @@ export class SdkError extends Error { message: string, public readonly data?: unknown ) { - super(message); + // If a call site passes an underlying error via the `data` slot as + // `{ cause }`, forward it to `Error` so the standard `.cause` chain + // stays unbroken — loggers and error trackers (pino, Sentry, …) walk + // `.cause` to reach the root cause. The full `data` object is still + // retained on `this.data`. See modelcontextprotocol/typescript-sdk#2657. + super(message, dataHasCause(data) ? { cause: data.cause } : undefined); this.name = 'SdkError'; stampErrorBrands(this, new.target); } } +/** + * Narrow an {@linkcode SdkError.data | data} value to one that carries a + * `cause`, so it can be forwarded to the `Error` constructor's `ErrorOptions`. + */ +function dataHasCause(data: unknown): data is { cause: unknown } { + return typeof data === 'object' && data !== null && 'cause' in data; +} + /** * Typed shape for HTTP error data carried by {@linkcode SdkHttpError}. */ diff --git a/packages/core-internal/test/errors/sdkErrorCause.test.ts b/packages/core-internal/test/errors/sdkErrorCause.test.ts new file mode 100644 index 0000000000..6634a70d6e --- /dev/null +++ b/packages/core-internal/test/errors/sdkErrorCause.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { SdkError, SdkErrorCode, SdkHttpError } from '../../src/index'; + +describe('SdkError cause forwarding (#2657)', () => { + it('forwards a `cause` from the data slot to Error.cause', () => { + const root = new Error('getaddrinfo ENOTFOUND does-not-resolve.invalid'); + const error = new SdkError(SdkErrorCode.EraNegotiationFailed, 'Version negotiation probe failed: fetch failed', { + cause: root + }); + + // The standard `.cause` chain must reach the underlying error, so + // pino/Sentry-style walkers surface the real failure. + expect(error.cause).toBe(root); + }); + + it('still keeps the full object on `data` (does not move it out)', () => { + const root = new Error('boom'); + const error = new SdkError(SdkErrorCode.EraNegotiationFailed, 'msg', { cause: root, extra: 1 }); + + expect(error.cause).toBe(root); + expect(error.data).toEqual({ cause: root, extra: 1 }); + }); + + it('leaves cause undefined when data carries none', () => { + const error = new SdkError(SdkErrorCode.NotConnected, 'Transport is not connected', { + status: 401 + }); + + expect(error.cause).toBeUndefined(); + expect(error.data).toEqual({ status: 401 }); + }); + + it('leaves cause undefined when there is no data', () => { + const error = new SdkError(SdkErrorCode.NotConnected, 'Transport is not connected'); + + expect(error.cause).toBeUndefined(); + }); + + it('does not treat an HTTP data payload as a cause', () => { + const error = new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Unauthorized', { + status: 401, + statusText: 'Unauthorized' + }); + + expect(error.cause).toBeUndefined(); + expect(error.status).toBe(401); + }); +});