Affects: @modelcontextprotocol/client@2.0.0-beta.5
Two problems in the server/discover negotiation probe path mean a client using versionNegotiation auto or { pin } never starts authorization against an OAuth-protected server. The same server authorizes fine on the legacy era.
1. classifyHttpError ignores the HTTP status
It parses only a JSON-RPC error body from the probe response; outcome.status is never read:
function classifyHttpError(outcome, context) {
const rpcError = parseJsonRpcErrorBody(outcome.body);
if (rpcError !== void 0) return classifyRpcError(rpcError, context);
return { kind: "legacy" };
}
A 401 or 403 on the probe therefore verdicts "no modern evidence" — indistinguishable from a 2025-era server — regardless of body shape. A server that answers 401 on server/discover is misread as legacy.
2. Pin mode rethrows without a cause
On that verdict, negotiateEra under kind: "pin" throws (index.mjs:2670 in the beta.5 build):
if (negotiation.kind === "pin") throw new SdkError(SdkErrorCode.EraNegotiationFailed, `Version negotiation failed: the server did not offer pinned protocol version ${negotiation.version} via server/discover (no fallback in pin mode)`);
No third argument, so the original SdkHttpError — status, body, everything — is discarded. The caller has no way to recover the 401, not even by walking the chain. Observed on a live server: the rejection is an SdkError/ERA_NEGOTIATION_FAILED with data undefined and no cause.
Related
normalizeReply maps a send rejection to auth-required only for SdkHttpError and UnauthorizedError. Any other typed auth error a consumer's transport raises falls through to network-error and is wrapped by classifyNetworkError as ERA_NEGOTIATION_FAILED with the original at data.cause. That one is recoverable, but only if the consumer knows to look there — and note it is data.cause, not cause, since SdkError's third parameter is data.
Impact and repro
Connecting to an OAuth-protected HTTP server with versionNegotiation: { mode: "auto" } or { mode: { pin: "2026-07-28" } } surfaces a dead-end negotiation failure instead of starting the OAuth flow. Reproduced against a live dual-era server whose bearer middleware guards the modern leg, so the probe itself is answered 401.
Full write-up, including a table of what connect() rejects with per era on both a direct and an intercepting transport: modelcontextprotocol/inspector#1805.
Asks
- Treat HTTP 401/403 on the probe as
auth-required rather than as absence of modern evidence.
- Preserve the original error as
cause when pin mode rethrows, so a status is recoverable even if (1) is declined.
MCP Inspector is working around this client-side (modelcontextprotocol/inspector#1806) by forcing auth-challenge interception on the probing eras, so the 401 becomes a typed error that survives the probe as data.cause. That workaround can be dropped once (1) lands.
Affects:
@modelcontextprotocol/client@2.0.0-beta.5Two problems in the
server/discovernegotiation probe path mean a client usingversionNegotiationautoor{ pin }never starts authorization against an OAuth-protected server. The same server authorizes fine on the legacy era.1.
classifyHttpErrorignores the HTTP statusIt parses only a JSON-RPC error body from the probe response;
outcome.statusis never read:A 401 or 403 on the probe therefore verdicts "no modern evidence" — indistinguishable from a 2025-era server — regardless of body shape. A server that answers 401 on
server/discoveris misread as legacy.2. Pin mode rethrows without a
causeOn that verdict,
negotiateEraunderkind: "pin"throws (index.mjs:2670in the beta.5 build):No third argument, so the original
SdkHttpError— status, body, everything — is discarded. The caller has no way to recover the 401, not even by walking the chain. Observed on a live server: the rejection is anSdkError/ERA_NEGOTIATION_FAILEDwithdataundefined and nocause.Related
normalizeReplymaps asendrejection toauth-requiredonly forSdkHttpErrorandUnauthorizedError. Any other typed auth error a consumer's transport raises falls through tonetwork-errorand is wrapped byclassifyNetworkErrorasERA_NEGOTIATION_FAILEDwith the original atdata.cause. That one is recoverable, but only if the consumer knows to look there — and note it isdata.cause, notcause, sinceSdkError's third parameter isdata.Impact and repro
Connecting to an OAuth-protected HTTP server with
versionNegotiation: { mode: "auto" }or{ mode: { pin: "2026-07-28" } }surfaces a dead-end negotiation failure instead of starting the OAuth flow. Reproduced against a live dual-era server whose bearer middleware guards the modern leg, so the probe itself is answered 401.Full write-up, including a table of what
connect()rejects with per era on both a direct and an intercepting transport: modelcontextprotocol/inspector#1805.Asks
auth-requiredrather than as absence of modern evidence.causewhen pin mode rethrows, so a status is recoverable even if (1) is declined.MCP Inspector is working around this client-side (modelcontextprotocol/inspector#1806) by forcing auth-challenge interception on the probing eras, so the 401 becomes a typed error that survives the probe as
data.cause. That workaround can be dropped once (1) lands.