diff --git a/.changeset/probe-network-error-cause.md b/.changeset/probe-network-error-cause.md new file mode 100644 index 0000000000..27df080c9d --- /dev/null +++ b/.changeset/probe-network-error-cause.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Put the version-negotiation probe's underlying network failure on `Error.cause`. `SdkError`'s third constructor parameter is `data`, not `ErrorOptions`, so `classifyNetworkError` passing `{ cause: error }` left `Error.cause` undefined and stranded the failure at `error.data.cause`. Anything walking the standard `.cause` chain — loggers, error reporters, `util.inspect` — stopped at the `SdkError` and never reached the `TypeError: fetch failed`, nor the DNS or socket error beneath it that names the actual problem. diff --git a/packages/client/src/client/probeClassifier.ts b/packages/client/src/client/probeClassifier.ts index e23d011a2a..8f89ed127c 100644 --- a/packages/client/src/client/probeClassifier.ts +++ b/packages/client/src/client/probeClassifier.ts @@ -309,12 +309,14 @@ function classifyNetworkError(error: unknown, context: ProbeClassifierContext): // where the probe's 2026 headers could not. return { kind: 'legacy' }; } - return { - kind: 'error', - error: new SdkError(SdkErrorCode.EraNegotiationFailed, `Version negotiation probe failed: ${describeError(error)}`, { - cause: error - }) - }; + // `SdkError`'s third parameter is `data`, not `ErrorOptions` — passing + // `{ cause: error }` there strands the underlying network failure at + // `error.data.cause`, where nothing walking the standard `.cause` chain + // will find it. Set `cause` on the instance instead, matching the + // non-enumerable descriptor the `Error` constructor would have produced. + const sdkError = new SdkError(SdkErrorCode.EraNegotiationFailed, `Version negotiation probe failed: ${describeError(error)}`); + Object.defineProperty(sdkError, 'cause', { value: error, writable: true, configurable: true }); + return { kind: 'error', error: sdkError }; } function isOpaqueFetchTypeError(error: unknown): boolean { diff --git a/packages/client/test/client/probeAuthSeam.test.ts b/packages/client/test/client/probeAuthSeam.test.ts index a347d71cd5..265928a48e 100644 --- a/packages/client/test/client/probeAuthSeam.test.ts +++ b/packages/client/test/client/probeAuthSeam.test.ts @@ -283,6 +283,6 @@ 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); - expect(((out.error as SdkError).data as { cause?: unknown }).cause).toBe(netError); + expect((out.error as SdkError).cause).toBe(netError); }); }); diff --git a/packages/client/test/client/probeClassifier.test.ts b/packages/client/test/client/probeClassifier.test.ts index 3a65f240b7..38f7c70dad 100644 --- a/packages/client/test/client/probeClassifier.test.ts +++ b/packages/client/test/client/probeClassifier.test.ts @@ -270,6 +270,20 @@ 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 on the standard `.cause` chain', () => { + 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') { + // Walking `.cause` — what every logger and error reporter does — + // must reach the error that actually names the failure, rather + // than dead-ending on the SdkError. + expect(verdict.error.cause).toBe(fetchError); + expect((verdict.error.cause as Error).cause).toBe(dnsError); + } + }); }); describe('row: timeout — transport-aware verdict', () => {