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