From ca7a7874f6762e0dc348a35dd00a9ba8e5677d11 Mon Sep 17 00:00:00 2001 From: LuckTerence Date: Thu, 27 Aug 2026 01:58:07 +0800 Subject: [PATCH 1/5] fix(client): surface underlying network error via Error.cause on probe failures (#2657) --- packages/client/src/client/probeClassifier.ts | 9 ++++++--- packages/client/test/client/probeClassifier.test.ts | 9 +++++++++ packages/core-internal/src/errors/sdkErrors.ts | 5 +++-- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/client/src/client/probeClassifier.ts b/packages/client/src/client/probeClassifier.ts index e23d011a2a..cbe3233051 100644 --- a/packages/client/src/client/probeClassifier.ts +++ b/packages/client/src/client/probeClassifier.ts @@ -311,9 +311,12 @@ function classifyNetworkError(error: unknown, context: ProbeClassifierContext): } return { kind: 'error', - error: new SdkError(SdkErrorCode.EraNegotiationFailed, `Version negotiation probe failed: ${describeError(error)}`, { - cause: error - }) + error: new SdkError( + SdkErrorCode.EraNegotiationFailed, + `Version negotiation probe failed: ${describeError(error)}`, + undefined, + { cause: error } + ) }; } diff --git a/packages/client/test/client/probeClassifier.test.ts b/packages/client/test/client/probeClassifier.test.ts index 3a65f240b7..420a446d0c 100644 --- a/packages/client/test/client/probeClassifier.test.ts +++ b/packages/client/test/client/probeClassifier.test.ts @@ -270,6 +270,15 @@ describe('row: network outage → typed connect error (Node)', () => { const verdict = classify({ kind: 'network-error', error: new TypeError('fetch failed') }, { environment: 'node' }); expect(verdict.kind).toBe('error'); }); + + test('the underlying network error is reachable via Error.cause (#2657)', () => { + const cause = Object.assign(new Error('fetch failed'), { code: 'ECONNREFUSED' }); + const verdict = classify({ kind: 'network-error', error: cause }); + expect(verdict.kind).toBe('error'); + if (verdict.kind === 'error') { + expect(verdict.error.cause).toBe(cause); + } + }); }); describe('row: timeout — transport-aware verdict', () => { diff --git a/packages/core-internal/src/errors/sdkErrors.ts b/packages/core-internal/src/errors/sdkErrors.ts index 0bc8f9a1ad..645068586f 100644 --- a/packages/core-internal/src/errors/sdkErrors.ts +++ b/packages/core-internal/src/errors/sdkErrors.ts @@ -147,9 +147,10 @@ export class SdkError extends Error { constructor( public readonly code: SdkErrorCode, message: string, - public readonly data?: unknown + public readonly data?: unknown, + options?: ErrorOptions ) { - super(message); + super(message, options); this.name = 'SdkError'; stampErrorBrands(this, new.target); } From 5211a075ccd34500c5a4bd01d22571114e90eeeb Mon Sep 17 00:00:00 2001 From: LuckTerence Date: Thu, 27 Aug 2026 14:19:33 +0800 Subject: [PATCH 2/5] chore: add changeset for SdkError cause fix --- .changeset/brave-donkeys-listen.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/brave-donkeys-listen.md diff --git a/.changeset/brave-donkeys-listen.md b/.changeset/brave-donkeys-listen.md new file mode 100644 index 0000000000..4583423da6 --- /dev/null +++ b/.changeset/brave-donkeys-listen.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/core-internal': patch +'@modelcontextprotocol/client': patch +--- + +`SdkError` now accepts standard `ErrorOptions`, and version-negotiation probe failures surface the underlying network error via `Error.cause` (#2657). From 689f95a84e5d64a91448ccb65bfc8c686f6dead4 Mon Sep 17 00:00:00 2001 From: LuckTerence Date: Fri, 28 Aug 2026 00:41:18 +0800 Subject: [PATCH 3/5] fix(client): keep SdkError data.cause populated alongside Error.cause The probeAuthSeam control test reads the underlying network error via SdkError.data.cause; passing it only as ErrorOptions left data undefined and crashed the suite. Populate both slots so the legacy data.cause contract and the standard Error.cause chain (#2657) both hold. --- packages/client/src/client/probeClassifier.ts | 4 +++- packages/client/test/client/probeClassifier.test.ts | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/client/src/client/probeClassifier.ts b/packages/client/src/client/probeClassifier.ts index cbe3233051..f73d57b393 100644 --- a/packages/client/src/client/probeClassifier.ts +++ b/packages/client/src/client/probeClassifier.ts @@ -314,7 +314,9 @@ function classifyNetworkError(error: unknown, context: ProbeClassifierContext): error: new SdkError( SdkErrorCode.EraNegotiationFailed, `Version negotiation probe failed: ${describeError(error)}`, - undefined, + // Keep data.cause for existing consumers while also exposing the + // standard Error.cause chain (#2657). + { cause: error }, { cause: error } ) }; diff --git a/packages/client/test/client/probeClassifier.test.ts b/packages/client/test/client/probeClassifier.test.ts index 420a446d0c..5c80efb09b 100644 --- a/packages/client/test/client/probeClassifier.test.ts +++ b/packages/client/test/client/probeClassifier.test.ts @@ -277,6 +277,8 @@ describe('row: network outage → typed connect error (Node)', () => { expect(verdict.kind).toBe('error'); if (verdict.kind === 'error') { expect(verdict.error.cause).toBe(cause); + // The legacy data.cause slot stays populated too. + expect((verdict.error.data as { cause?: unknown }).cause).toBe(cause); } }); }); From 73f81d9534ad26d710a42e7abeef43aabb9d9a06 Mon Sep 17 00:00:00 2001 From: LuckTerence Date: Fri, 28 Aug 2026 00:53:43 +0800 Subject: [PATCH 4/5] test(client): narrow SdkError before reading data.cause in cause-chain test --- packages/client/test/client/probeClassifier.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/test/client/probeClassifier.test.ts b/packages/client/test/client/probeClassifier.test.ts index 5c80efb09b..d6e447407d 100644 --- a/packages/client/test/client/probeClassifier.test.ts +++ b/packages/client/test/client/probeClassifier.test.ts @@ -278,7 +278,7 @@ describe('row: network outage → typed connect error (Node)', () => { if (verdict.kind === 'error') { expect(verdict.error.cause).toBe(cause); // The legacy data.cause slot stays populated too. - expect((verdict.error.data as { cause?: unknown }).cause).toBe(cause); + expect(((verdict.error as SdkError).data as { cause?: unknown }).cause).toBe(cause); } }); }); From e3ff1ea415350ba2fb4698e0489f1ae568a3775e Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 3 Sep 2026 16:49:40 +0300 Subject: [PATCH 5/5] fix(core-internal): thread ErrorOptions through SdkHttpError and pin the Error.cause plumbing (#2657) - SdkHttpError accepts and forwards `options?: ErrorOptions`, matching SdkError - JSDoc for the new constructor parameter on both classes - errorSurfacePins: options-only, data-only, both, and SdkHttpError cases - probeClassifier regression test asserts the full TypeError -> ENOTFOUND chain - probeAuthSeam control test also asserts `.cause` - changeset: `data.cause` is retained for compatibility and slated for removal --- .changeset/brave-donkeys-listen.md | 3 +- .../client/test/client/probeAuthSeam.test.ts | 3 ++ .../test/client/probeClassifier.test.ts | 18 +++++--- .../core-internal/src/errors/sdkErrors.ts | 17 +++++++- .../test/types/errorSurfacePins.test.ts | 42 +++++++++++++++++++ 5 files changed, 75 insertions(+), 8 deletions(-) diff --git a/.changeset/brave-donkeys-listen.md b/.changeset/brave-donkeys-listen.md index 4583423da6..c6b92902e1 100644 --- a/.changeset/brave-donkeys-listen.md +++ b/.changeset/brave-donkeys-listen.md @@ -1,6 +1,7 @@ --- '@modelcontextprotocol/core-internal': patch '@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch --- -`SdkError` now accepts standard `ErrorOptions`, and version-negotiation probe failures surface the underlying network error via `Error.cause` (#2657). +`SdkError` and `SdkHttpError` accept standard `ErrorOptions` as an optional fourth constructor argument and forward it to `Error`, so a wrapped error is reachable through the standard `Error.cause` chain. Version-negotiation probe failures (`SdkErrorCode.EraNegotiationFailed`) now use it: the underlying `TypeError: fetch failed` and the DNS or socket error beneath it surface via `error.cause`, so pino, Sentry, and `util.inspect` render `ENOTFOUND` / `ECONNREFUSED` / `ETIMEDOUT` instead of stopping at the `SdkError` (#2657). The previous `error.data.cause` slot is still populated for compatibility but is deprecated and slated for removal; read `error.cause` instead. diff --git a/packages/client/test/client/probeAuthSeam.test.ts b/packages/client/test/client/probeAuthSeam.test.ts index a347d71cd5..a26276b11a 100644 --- a/packages/client/test/client/probeAuthSeam.test.ts +++ b/packages/client/test/client/probeAuthSeam.test.ts @@ -283,6 +283,9 @@ describe('stamped-seam fault injection (identity-preserving auth outcomes, never expect(out.settled).toBe('rejected'); expect(out.error).toBeInstanceOf(SdkError); expect((out.error as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + // The failure rides the standard cause chain (#2657); the legacy data.cause + // slot is kept populated for compatibility until it is removed. + expect((out.error as SdkError).cause).toBe(netError); expect(((out.error as SdkError).data as { cause?: unknown }).cause).toBe(netError); }); }); diff --git a/packages/client/test/client/probeClassifier.test.ts b/packages/client/test/client/probeClassifier.test.ts index d6e447407d..ccc1efd54a 100644 --- a/packages/client/test/client/probeClassifier.test.ts +++ b/packages/client/test/client/probeClassifier.test.ts @@ -272,13 +272,21 @@ describe('row: network outage → typed connect error (Node)', () => { }); test('the underlying network error is reachable via Error.cause (#2657)', () => { - const cause = Object.assign(new Error('fetch failed'), { code: 'ECONNREFUSED' }); - const verdict = classify({ kind: 'network-error', error: cause }); + // Node's fetch wraps the socket/DNS failure: `TypeError: fetch failed` with + // the error that actually names the failure (ENOTFOUND / ECONNREFUSED / + // ETIMEDOUT) on its own `cause`. + const dnsError = Object.assign(new Error('getaddrinfo ENOTFOUND unreachable.invalid'), { code: 'ENOTFOUND' }); + const fetchError = new TypeError('fetch failed', { cause: dnsError }); + const verdict = classify({ kind: 'network-error', error: fetchError }); expect(verdict.kind).toBe('error'); if (verdict.kind === 'error') { - expect(verdict.error.cause).toBe(cause); - // The legacy data.cause slot stays populated too. - expect(((verdict.error as SdkError).data as { cause?: unknown }).cause).toBe(cause); + // Walking `.cause` (what loggers and error reporters do) must reach the + // error that names the failure instead of dead-ending on the SdkError. + expect(verdict.error.cause).toBe(fetchError); + expect((verdict.error.cause as Error).cause).toBe(dnsError); + // The legacy data.cause slot stays populated too (kept for compatibility, + // slated for removal). + expect(((verdict.error as SdkError).data as { cause?: unknown }).cause).toBe(fetchError); } }); }); diff --git a/packages/core-internal/src/errors/sdkErrors.ts b/packages/core-internal/src/errors/sdkErrors.ts index 645068586f..3ad48cc7f6 100644 --- a/packages/core-internal/src/errors/sdkErrors.ts +++ b/packages/core-internal/src/errors/sdkErrors.ts @@ -144,6 +144,16 @@ export class SdkError extends Error { return brandedHasInstance(this, value); } + /** + * @param code - Stable string code identifying the failure ({@linkcode SdkErrorCode}). + * @param message - Human-readable description. + * @param data - Optional structured payload (for example the HTTP status carried by + * {@linkcode SdkHttpError}). Opaque to the SDK: a `cause` key inside `data` is not + * promoted to `Error.cause`. + * @param options - Standard `ErrorOptions`, forwarded to `Error`. Pass the underlying + * failure as `{ cause }` so it is reachable through the `Error.cause` chain that + * loggers and error trackers walk. + */ constructor( public readonly code: SdkErrorCode, message: string, @@ -188,8 +198,11 @@ export class SdkHttpError extends SdkError { declare readonly data: SdkHttpErrorData; - constructor(code: SdkErrorCode, message: string, data: SdkHttpErrorData) { - super(code, message, data); + /** + * @param options - Standard `ErrorOptions`, forwarded to `Error` (see {@linkcode SdkError}). + */ + constructor(code: SdkErrorCode, message: string, data: SdkHttpErrorData, options?: ErrorOptions) { + super(code, message, data, options); this.name = 'SdkHttpError'; } diff --git a/packages/core-internal/test/types/errorSurfacePins.test.ts b/packages/core-internal/test/types/errorSurfacePins.test.ts index cc01cf4c57..e2f52c2410 100644 --- a/packages/core-internal/test/types/errorSurfacePins.test.ts +++ b/packages/core-internal/test/types/errorSurfacePins.test.ts @@ -205,6 +205,48 @@ describe('SdkError', () => { expect(error.code).toBe('CLIENT_HTTP_FAILED_TO_OPEN_STREAM'); expect(error.data).toMatchObject({ status: 404 }); }); + + // Cause plumbing (#2657): a wrapped error travels on the standard `Error.cause` + // chain via `ErrorOptions`, never through the opaque `data` payload, so pino / + // Sentry / `util.inspect` reach the root failure without SDK-specific handling. + test('forwards ErrorOptions.cause onto Error.cause without touching data', () => { + const root = new TypeError('fetch failed'); + const error = new SdkError(SdkErrorCode.EraNegotiationFailed, 'Version negotiation probe failed', undefined, { + cause: root + }); + expect(error.cause).toBe(root); + expect(error.data).toBeUndefined(); + // Same non-enumerable own property the native Error constructor installs, + // so serializers that copy enumerable fields do not emit it twice. + expect(Object.getOwnPropertyDescriptor(error, 'cause')?.enumerable).toBe(false); + }); + + test('does not promote a `cause` key inside data to Error.cause', () => { + const root = new Error('boom'); + const error = new SdkError(SdkErrorCode.RequestTimeout, 'Request timed out', { timeout: 60_000, cause: root }); + expect(error.cause).toBeUndefined(); + expect(error.data).toEqual({ timeout: 60_000, cause: root }); + }); + + test('carries data and cause independently when both are passed', () => { + const root = new Error('boom'); + const error = new SdkError(SdkErrorCode.RequestTimeout, 'Request timed out', { timeout: 60_000 }, { cause: root }); + expect(error.cause).toBe(root); + expect(error.data).toEqual({ timeout: 60_000 }); + }); + + test('SdkHttpError forwards ErrorOptions.cause and keeps the HTTP status', () => { + const root = new Error('socket hang up'); + const error = new SdkHttpError( + SdkErrorCode.ClientHttpFailedToOpenStream, + 'Failed to open SSE stream: Bad Gateway', + { status: 502, statusText: 'Bad Gateway' }, + { cause: root } + ); + expect(error.cause).toBe(root); + expect(error.status).toBe(502); + expect(error.statusText).toBe('Bad Gateway'); + }); }); describe('protocol version constants', () => {