What happened?
Connecting to an unreachable host with versionNegotiation: { mode: "auto" } rejects with an SdkError whose cause is undefined. The underlying TypeError: fetch failed — and the DNS error beneath it that actually names the failure — is retained, but at error.data.cause. Anything walking the standard .cause chain stops at the SdkError.
classifyNetworkError passes { cause: error } as the third argument:
// dist/index.mjs:2400
return {
kind: "error",
error: new SdkError(SdkErrorCode.EraNegotiationFailed, `Version negotiation probe failed: ${describeError(error)}`, { cause: error })
};
But SdkError's third parameter is data, not ErrorOptions, and super() is called without it:
// dist/src-D_zzAWoS.mjs:342
var SdkError = class extends Error {
constructor(code, message, data) {
super(message); // <- options never forwarded
this.code = code;
this.data = data; // <- the { cause } object lands here
this.name = "SdkError";
stampErrorBrands(this, new.target);
}
};
So the { cause } ends up in the data slot. data is a legitimate parameter — SdkHttpError reads status/statusText off it — which is what makes this a call-site mismatch rather than an intended shape.
Why it matters
Cause-chain walking is how error detail reaches logs and error trackers: pino's default err serializer (pino-std-serializers) builds its message by recursing .cause, and Sentry links exception chains the same way. Both stop at the SdkError.
Same unreachable-host failure, same pino serializer, before and after moving a codebase from a v1-era MCP client to v2:
before: fetch failed: getaddrinfo ENOTFOUND my-server.internal
after: Version negotiation probe failed: fetch failed
Nothing was special about "before" — the chain was simply unbroken, so the serializer walked it. After, the operator loses the one token that identifies the failure: ENOTFOUND (wrong host / bad DNS) vs ECONNREFUSED (nothing listening) vs ETIMEDOUT (firewall) all render identically as fetch failed.
A consumer can recover it by special-casing .data.cause, but only if they know to look — and it means every .cause walker in the ecosystem needs a v2-specific branch.
What did you expect?
error.cause to be the original error, so the chain reads
SdkError: Version negotiation probe failed: fetch failed
└─ TypeError: fetch failed
└─ Error: getaddrinfo ENOTFOUND does-not-resolve.invalid
and standard tooling renders the root cause with no SDK-specific handling.
Two ways to close it:
- Forward the option in
SdkError — accept an ErrorOptions alongside data, or pass { cause } through to super when data carries one. This also covers any other site that hands { cause } to the data slot.
- Fix the call site so
classifyNetworkError constructs the error with a real ErrorOptions.
(1) seems safer, since the mismatch is silent at every call site and TypeScript won't catch it while data is loosely typed.
Code to reproduce
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"
const client = new Client(
{ name: "repro", version: "0.0.0" },
{ versionNegotiation: { mode: "auto" } },
)
const transport = new StreamableHTTPClientTransport(new URL("http://does-not-resolve.invalid:65419"))
try {
await client.connect(transport)
} catch (error) {
const e = error as Error & { code?: string; data?: { cause?: unknown } }
console.log("code: ", e.code)
console.log("message: ", e.message)
console.log("e.cause: ", e.cause) // undefined
console.log("e.data.cause: ", (e.data?.cause as Error)?.message)
console.log("e.data.cause.cause:", ((e.data?.cause as Error)?.cause as Error)?.message)
}
Output (Node 24.18.0, global undici fetch):
code: ERA_NEGOTIATION_FAILED
message: Version negotiation probe failed: fetch failed
e.cause: undefined
e.data.cause: fetch failed
e.data.cause.cause: getaddrinfo ENOTFOUND does-not-resolve.invalid
SDK version
@modelcontextprotocol/client@2.0.0
Area
Client
Related
#2561 noted the data-vs-cause mismatch in passing, in a "Related" aside — its two asks were about probe 401/403 classification, and the HTTP-status half of that does look addressed in 2.0.0. This is the constructor-level half, which is what makes the stranded cause invisible to every standard walker rather than merely awkward to reach.
What happened?
Connecting to an unreachable host with
versionNegotiation: { mode: "auto" }rejects with anSdkErrorwhosecauseisundefined. The underlyingTypeError: fetch failed— and the DNS error beneath it that actually names the failure — is retained, but aterror.data.cause. Anything walking the standard.causechain stops at theSdkError.classifyNetworkErrorpasses{ cause: error }as the third argument:But
SdkError's third parameter isdata, notErrorOptions, andsuper()is called without it:So the
{ cause }ends up in thedataslot.datais a legitimate parameter —SdkHttpErrorreadsstatus/statusTextoff it — which is what makes this a call-site mismatch rather than an intended shape.Why it matters
Cause-chain walking is how error detail reaches logs and error trackers: pino's default
errserializer (pino-std-serializers) builds its message by recursing.cause, and Sentry links exception chains the same way. Both stop at theSdkError.Same unreachable-host failure, same pino serializer, before and after moving a codebase from a v1-era MCP client to v2:
Nothing was special about "before" — the chain was simply unbroken, so the serializer walked it. After, the operator loses the one token that identifies the failure:
ENOTFOUND(wrong host / bad DNS) vsECONNREFUSED(nothing listening) vsETIMEDOUT(firewall) all render identically asfetch failed.A consumer can recover it by special-casing
.data.cause, but only if they know to look — and it means every.causewalker in the ecosystem needs a v2-specific branch.What did you expect?
error.causeto be the original error, so the chain readsand standard tooling renders the root cause with no SDK-specific handling.
Two ways to close it:
SdkError— accept anErrorOptionsalongsidedata, or pass{ cause }through tosuperwhendatacarries one. This also covers any other site that hands{ cause }to thedataslot.classifyNetworkErrorconstructs the error with a realErrorOptions.(1) seems safer, since the mismatch is silent at every call site and TypeScript won't catch it while
datais loosely typed.Code to reproduce
Output (Node 24.18.0, global undici
fetch):SDK version
@modelcontextprotocol/client@2.0.0Area
Client
Related
#2561 noted the
data-vs-causemismatch in passing, in a "Related" aside — its two asks were about probe 401/403 classification, and the HTTP-status half of that does look addressed in 2.0.0. This is the constructor-level half, which is what makes the stranded cause invisible to every standard walker rather than merely awkward to reach.