Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/probe-network-error-cause.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 8 additions & 6 deletions packages/client/src/client/probeClassifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/client/test/client/probeAuthSeam.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
14 changes: 14 additions & 0 deletions packages/client/test/client/probeClassifier.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading