Skip to content

[v2] classifyNetworkError passes { cause } into SdkError's data slot, so the underlying network error never reaches Error.cause #2657

Description

@itsjamie

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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions