Skip to content

server/discover probe classifier discards HTTP 401/403, and pin mode drops the cause #2561

Description

@cliffhall

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

  1. Treat HTTP 401/403 on the probe as auth-required rather than as absence of modern evidence.
  2. 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.

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