Skip to content
Merged
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
28 changes: 28 additions & 0 deletions .changeset/probe-auth-status-not-era-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@modelcontextprotocol/client': patch
---

The version-negotiation probe no longer misclassifies auth-protected or
failing servers as legacy. Auth status is never era evidence: a 401 or 403
rejection of the `server/discover` probe now surfaces as a typed
authorization failure — an `SdkHttpError` with code `ClientHttpAuthentication`
(401) or `ClientHttpForbidden` (403), carrying the HTTP status, reason
phrase, and response text — instead of triggering the legacy `initialize`
fallback (which put a doomed `initialize` on the wire) or, under `pin` mode,
the false "server did not offer pinned protocol version" diagnostic. The
codes are deliberately not `EraNegotiationFailed`, so era-recovery flows
keyed on that code cannot persist a verdict for an unauthorized exchange. A
5xx rejecting the probe is a server failure and now also rejects typed
(`SdkHttpError(EraNegotiationFailed)`) instead of demoting a mid-deploy
modern server to legacy — the legacy fallback now fires only on the 4xx
shapes the spec licenses.

With an `authProvider`, a `401` (and a `403` `insufficient_scope` challenge) runs the transport's auth flow first — a plain `403` rejects the same as without a provider — and whatever
escapes it propagates unchanged, identity intact: the HTTP transports stamp
errors at their auth seams (the `token()` read, `onUnauthorized` including
custom callbacks, the 403 step-up flow, and their own auth-failure
constructions), so `UnauthorizedError` for `finishAuth()`, the flow's typed
failures (`OAuthError`, `InsufficientScopeError`, the
401-after-re-authentication diagnostic), and even an untyped `TypeError`
thrown inside the flow all reach the caller as thrown — never rewrapped,
never consumed by the probe's browser CORS heuristic as legacy-era evidence.
2 changes: 2 additions & 0 deletions docs/advanced/gateway.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ re-probed: 2026-07-28

Replace the persisted blob with the fresh `getDiscoverResult()` and the rest of the fleet recovers on its next read.

The `EraNegotiationFailed` filter above is deliberate and safe against auth walls: a `401`/`403` rejecting the probe carries `ClientHttpAuthentication`/`ClientHttpForbidden` instead, so an unauthorized exchange re-throws out of this recovery path and can never be persisted as an era verdict.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The absolute claim added at docs/advanced/gateway.md:154 — that a 401/403 rejecting the probe always carries ClientHttpAuthentication/ClientHttpForbidden and "can never be persisted as an era verdict" — is false over the legacy SSEClientTransport: its _send has no typed HTTP rejection for a providerless 401 (and no 403 branch at all), so the plain Error it throws is classified as a network failure and wrapped as SdkError(EraNegotiationFailed), which DOES match the gateway recipe's EraNegotiationFailed-keyed filter. The PR's own docs/protocol-versions.md paragraph already concedes exactly this SSE caveat, contradicting gateway.md and the changeset headline. Fix: qualify the gateway.md sentence (and the changeset) with the same Streamable-HTTP-only caveat protocol-versions.md carries.

Extended reasoning...

The claim. docs/advanced/gateway.md:154 (added by this PR) states: "a 401/403 rejecting the probe carries ClientHttpAuthentication/ClientHttpForbidden instead, so an unauthorized exchange re-throws out of this recovery path and can never be persisted as an era verdict." The changeset .changeset/probe-auth-status-not-era-evidence.md makes the same unqualified headline claim ("a 401 or 403 rejection of the server/discover probe now surfaces as … an SdkHttpError with code ClientHttpAuthentication (401) or ClientHttpForbidden (403)").

Why it's false over the legacy SSE transport. In packages/client/src/client/sse.ts, SSEClientTransport._send's only auth branch is gated on response.status === 401 && this._authProvider, and there is no 403 branch at all. A providerless 401 — or any 403 — on the probe POST falls through to the generic throw new Error(Error POSTing to endpoint (HTTP ${response.status}): ${text}). That plain Error carries no auth-seam stamp (markAuthSeamEscape is only applied to the token() read, onUnauthorized escapes, and the typed UnauthorizedError/SdkHttpError throws), is not an UnauthorizedError, and is not an SdkHttpError.

The classification chain. normalizeReply's send-error row in packages/client/src/client/versionNegotiation.ts: every clause of the auth disjunction misses (no stamp, wrong class, wrong name), and the SdkHttpError branch misses too — so the error lands in { kind: 'network-error' }. classifyNetworkError in packages/client/src/client/probeClassifier.ts then wraps it as SdkError(SdkErrorCode.EraNegotiationFailed, 'Version negotiation probe failed: …'). That is precisely the code the gateway recipe's recovery filter keys on — the exact outcome gateway.md:154 says "can never" happen.

Step-by-step proof. (1) A client uses versionNegotiation: { mode: 'auto' } with an SSEClientTransport and no authProvider against a server whose GET stream opens but whose POST endpoint sits behind an auth wall (returns 403). detectProbeTransportKind returns 'http' for SSE (no stderr/pid accessors), so the probing modes run on it. (2) The probe POST gets the 403; _send has no 403 branch, so it throws the plain Error('Error POSTing to endpoint (HTTP 403): …'). (3) normalizeReplynetwork-errorclassifyNetworkErrorSdkError(EraNegotiationFailed). (4) A gateway following the recipe's error.code === SdkErrorCode.EraNegotiationFailed filter enters the recovery path for an unauthorized exchange — contradicting the sentence's "re-throws out of this recovery path".

The PR is internally inconsistent about this. Its own docs/protocol-versions.md paragraph (same diff) explicitly concedes the gap: "the legacy SSE client transport reports a non-2xx POST as a generic error, so over SSE a probe rejection surfaces as the generic Version negotiation probe failed connect error instead." gateway.md:154 and the changeset omit that qualification while stating the guarantee absolutely.

Why this is a nit, not blocking. No code misbehaves — this is prose-only. SSE is deprecated, every gateway.md example uses StreamableHTTPClientTransport, and even in the SSE scenario the ultimate hazard does not materialize: the recovery branch's re-probe fails against the same auth wall, so connect() rejects before getDiscoverResult() can be read and no wrong verdict is persisted. This matches the repo's Documentation & Changesets recurring catch (prose promising behavior the diff doesn't back).

Fix. Add the same one-clause transport qualification protocol-versions.md already carries to both gateway.md:154 and the changeset headline — e.g. "…carries ClientHttpAuthentication/ClientHttpForbidden instead (on the Streamable HTTP transport; the legacy SSE client transport surfaces an auth-walled POST as the generic EraNegotiationFailed probe failure)…".


## Skip the probe for a known-legacy server

When out-of-band metadata already says the server is pre-2026 — a registry entry, an earlier connection's outcome — an `'auto'`-mode probe is a round trip that fails on every single connect. Supply the negative verdict instead: `PriorDiscovery`'s `{ kind: 'legacy' }` arm skips the probe and goes straight to the `initialize` handshake.
Expand Down
2 changes: 1 addition & 1 deletion docs/clients/oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ try {
When the server requires authorization and the provider has no token, the SDK runs discovery against the server, registers (or looks up) your OAuth client, calls the provider's `redirectToAuthorization(url)`, and `connect()` throws `UnauthorizedError`. The end user finishes signing in out of band; your callback endpoint picks the flow back up below.

::: info
With protocol-version negotiation in play, the connect-time 401 can also surface as an `SdkError` carrying the `UnauthorizedError` at `error.data.cause` — see [Protocol versions](../protocol-versions.md).
With protocol-version negotiation in play (`versionNegotiation: { mode: 'auto' }` or a pin), the connect-time `UnauthorizedError` propagates unchanged from `connect()` — the same `instanceof` check works in every mode (older releases wrapped it as an `SdkError` with the error at `error.data.cause`). See [Protocol versions](../protocol-versions.md).
:::

## Implement OAuthClientProvider
Expand Down
10 changes: 9 additions & 1 deletion docs/migration/support-2026-07-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,15 @@ infrastructure problems. Anything the probe does not positively recognize as mod
falls back to the legacy era — provided the supported-versions list still contains a
2025-era revision; with a modern-only list `connect()` rejects with
`SdkError(EraNegotiationFailed)` instead. A network outage rejects with a typed connect
error. Probe timeouts are **transport-aware**: on **stdio** a server that does not
error. Auth statuses are another exception: an HTTP `401` or `403` rejecting the probe
is never era evidence — `connect()` rejects with a typed authorization failure (an
`SdkHttpError` with code `ClientHttpAuthentication`/`ClientHttpForbidden` naming the
status, or the transport auth flow's own error propagated unchanged) instead of
falling back — see [Protocol versions](../protocol-versions.md). This holds even for
a front that answers the probe `403` but would pass `initialize`: relying on a legacy
fallback there is a deliberate non-goal — fix the auth wall (or pass credentials),
because auth status never selects an era. A `5xx` on the probe is a server failure,
also never era evidence: `connect()` rejects with `SdkHttpError(EraNegotiationFailed)`. Probe timeouts are **transport-aware**: on **stdio** a server that does not
answer within `timeoutMs` is treated as legacy and the client falls back to `initialize`
(some legacy servers never respond to unknown pre-`initialize`
requests at all); on **HTTP** a probe timeout rejects with `SdkError(RequestTimeout)` —
Expand Down
10 changes: 5 additions & 5 deletions docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -1150,11 +1150,11 @@ try {
}
```

One qualification: this direct `instanceof` check applies under the default `'legacy'`
version negotiation. Under the probing modes (`versionNegotiation: { mode: 'auto' }`,
with or without a pin) the connect-time 401 currently surfaces wrapped as
`SdkError(SdkErrorCode.EraNegotiationFailed)` with the `UnauthorizedError` at
`error.data.cause` — unwrap before the check, as shown in the
This direct `instanceof` check works in every version-negotiation mode: under the
probing modes (`versionNegotiation: { mode: 'auto' }`, with or without a pin) the
connect-time `UnauthorizedError` also propagates unchanged from `connect()`. Older
releases wrapped it as `SdkError(SdkErrorCode.EraNegotiationFailed)` with the error at
`error.data.cause` — that unwrap is no longer needed. See the
[client OAuth guide](../clients/oauth.md).
Comment thread
claude[bot] marked this conversation as resolved.

#### `auth()` options are now `AuthOptions`
Expand Down
2 changes: 2 additions & 0 deletions docs/protocol-versions.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ const cli = new Client(

A probe timeout is transport-aware. On stdio a silent server is a legacy server, so `connect()` falls back to `initialize`; on HTTP silence is an outage, so `connect()` rejects with `SdkError(RequestTimeout)` instead of misreporting a dead server as legacy. One browser exception: an opaque CORS `TypeError` during the probe falls back to the legacy era, because deployed 2025 servers commonly have allow-lists that predate the 2026 headers.

Auth statuses are not era evidence either. An HTTP `401` or `403` rejecting the probe surfaces as a typed authorization failure, never the legacy fallback — and never as `EraNegotiationFailed`, so era-recovery flows keyed on that code (the [gateway guide](./advanced/gateway.md) recipe) cannot consume an auth wall. A plain `401` or `403` — with or without an `authProvider` — rejects `connect()` with an `SdkHttpError` carrying the status: code `ClientHttpAuthentication` for `401`, `ClientHttpForbidden` for `403`. One 403 shape is different: a `WWW-Authenticate` challenge with `error="insufficient_scope"` enters the Streamable HTTP transport's step-up flow regardless of provider, and with none it rejects with the flow's typed `InsufficientScopeError`. With a provider, a `401` (and a `403` `insufficient_scope` challenge) runs the auth flow first, and whatever escapes it reaches you as thrown, identity intact — the transport stamps errors at its auth seams (the `token()` read, `onUnauthorized` including custom callbacks, step-up), so `UnauthorizedError` for `finishAuth()`, the flow's typed failures (`OAuthError`, `InsufficientScopeError`, the 401-after-re-authentication diagnostic), and even an untyped crash inside a callback all propagate unchanged. A `5xx` rejecting the probe is a server failure, not era evidence: `connect()` rejects with `SdkHttpError(EraNegotiationFailed)` naming the status. These status-keyed rows read the transport's typed HTTP rejections — the SDK's Streamable HTTP transport surfaces them with the status attached; the legacy SSE client transport reports a non-2xx POST as a generic error, so over SSE a probe rejection surfaces as the generic `Version negotiation probe failed` connect error instead. Auth settles first, era second: a `401` never decides the era — the auth wall answers before the MCP layer ever sees `server/discover` — and the post-auth re-probe supplies the real era evidence.

On the SDK's own stdio transport (exactly `StdioClientTransport` — subclasses, like custom stdio-shaped transports, probe in place) the probe runs on a short-lived **sibling process** spawned from the same parameters — some stdio servers exit on any pre-`initialize` request (servers built on the official Rust SDK, rmcp, behave this way), so the probe must not spend the caller's one child process. The sibling is invisible infrastructure: its stderr is discarded and it is reaped once the era is known; the caller's transport spawns exactly once, afterwards, and its wire never carries `server/discover`. A child that exits on the probe is simply a legacy server (its exit must close the child's stdio pipes to register — an exit hidden behind a helper process holding them open falls to the probe-timeout path). Closing the caller's transport during the probe aborts `connect()` with a typed `SdkError(EraNegotiationFailed)` and the session child is never spawned. On HTTP — and on custom stdio-shaped transports, which probe in place — a mid-probe connection close rejects with the same typed error as any probe transport failure.

The client's `supportedProtocolVersions` option shapes the probe: its 2026+ entries are the versions the probe offers, and the legacy fallback stays available only while the list keeps a pre-2026 entry. A list with no pre-2026 entry removes the fallback — against a 2025-only server, `connect()` rejects with `SdkError(EraNegotiationFailed)`.
Expand Down
9 changes: 9 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ With the global in place the [client OAuth](./clients/oauth.md) flows run unchan
- `the connection closed during the server/discover probe (this transport probed in place — the disposable sibling probe requires the SDK's base StdioClientTransport)` — a subclass of `StdioClientTransport`, or a custom stdio-shaped transport, probed in place and met a server that exits on any pre-`initialize` request: use the base `StdioClientTransport` (which probes on a disposable sibling), or `mode: 'legacy'`.
- `the transport was closed during the server/discover probe` — the caller closed the transport while the probe was in flight; the connect aborted deliberately and the session child was never spawned.
- `Version negotiation probe failed: ...` — the probe hit a transport failure (network outage, HTTP connection drop): fix connectivity and retry.
- `the server answered the probe with HTTP 5xx` — the server or a proxy in front of it failed (mid-deploy, crashed backend); not era evidence, so no legacy fallback is attempted: retry once the deployment is healthy.

A `401`/`403` probe rejection is **not** this code — see the next section.

The pinned shape — `transport` here reaches a server still on the 2025 revisions ([Test a server](./testing.md) shows the in-memory wiring these outputs come from):

Expand Down Expand Up @@ -112,6 +115,12 @@ legacy

Keep `{ pin }` where a legacy connection is unacceptable and a hard failure is the behavior you want. [Protocol versions](./protocol-versions.md) defines the eras and what each negotiation mode offers.

## `Version negotiation failed: the server requires authorization (HTTP 401)`

`SdkHttpError` with code `CLIENT_HTTP_AUTHENTICATION` (`error.status === 401`): the negotiation probe hit an auth wall and no `authProvider` is configured. Auth status is never protocol-era evidence, so `connect()` fails typed instead of guessing an era. Pass an `authProvider` — `{ token: async () => myApiKey }` for bearer tokens, or an `OAuthClientProvider` for OAuth flows (the connect-time `UnauthorizedError` → `finishAuth()` → reconnect cycle then works in every negotiation mode).

The `403` sibling — `Version negotiation failed: the server denied access (HTTP 403)`, code `CLIENT_HTTP_FORBIDDEN` — means the server refused the request outright (IP allowlist, org policy, revoked key). Fix access; this is not a protocol mismatch. (A `403` whose `WWW-Authenticate` challenge carries `error="insufficient_scope"` instead rejects with `InsufficientScopeError` — request the challenged scope.)

## `SdkError: METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION`

You sent a spec method the negotiated protocol era does not define. The SDK raises this locally — nothing reached the transport — and the message names the method, the negotiated revision, and the era-appropriate replacement.
Expand Down
16 changes: 3 additions & 13 deletions examples/cli-client/host/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,13 @@ import {
Client,
LOG_LEVEL_META_KEY,
ProtocolError,
SdkError,
StreamableHTTPClientTransport,
SUPPORTED_PROTOCOL_VERSIONS,
UnauthorizedError
} from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';

import type { ChatMessage, ContentPart, GenerateResult, LLMProvider, ToolCall, ToolDefinition } from '../providers/provider';
import { isRecord } from '../providers/provider';
import { completeAuthorizationWithBrowser, createOAuthProvider, findCallbackPort, isSafeBrowserUrl } from './auth';
import type { CliClientConfig, ServerConfig } from './config';
import { isHttpServer } from './config';
Expand Down Expand Up @@ -100,16 +98,6 @@ export function resolveVersionOptions(legacy: boolean, protocolVersion?: string)
return { versionNegotiation: { mode: { pin: protocolVersion } } };
}

function unwrapUnauthorized(error: unknown): UnauthorizedError | undefined {
if (error instanceof UnauthorizedError) return error;
// Under versionNegotiation 'auto', a connect-time 401 surfaces as
// SdkError(EraNegotiationFailed) with the UnauthorizedError in error.data.cause.
if (error instanceof SdkError && isRecord(error.data) && error.data.cause instanceof UnauthorizedError) {
return error.data.cause;
}
return undefined;
}

function samplingContentToParts(content: CreateMessageRequest['params']['messages'][number]['content']): ContentPart[] {
const blocks = Array.isArray(content) ? content : [content];
const parts: ContentPart[] = [];
Expand Down Expand Up @@ -488,7 +476,9 @@ export class McpHost {
try {
await client.connect(httpTransport);
} catch (error) {
if (!unwrapUnauthorized(error)) throw error;
// A connect-time 401 propagates UnauthorizedError unchanged
// in every negotiation mode, including the probing ones.
if (!(error instanceof UnauthorizedError)) throw error;
const finishTransport = httpTransport;
const authorized = await completeAuthorizationWithBrowser({
serverName: name,
Expand Down
40 changes: 40 additions & 0 deletions packages/client/src/client/authSeam.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Auth-seam provenance stamp (internal — not part of the public API).
*
* Errors escaping a transport auth seam — the `authProvider.token()` read,
* an `onUnauthorized` invocation (SDK adapter or custom user callback), the
* 403 step-up flow, the transport's own auth-failure constructions — are
* stamped at the throw boundary. The version-negotiation probe routes stamped
* errors as auth outcomes; provenance is recorded where it is known, never
* reconstructed from error types downstream.
*
* `Symbol.for` uses the global symbol registry, so the stamp survives a
* duplicated SDK copy in one process (bundler double-install, version skew)
* by design: both copies resolve the same symbol.
*/
const AUTH_SEAM = Symbol.for('mcp.authSeamEscape');

/**
* Stamp `error` as an auth-seam escape and return it — identity-preserving
* (the same object flows on, `instanceof` and `.cause` chains intact). A
* frozen/sealed object is returned unstamped rather than replaced: identity
* outranks provenance. Primitive throws cannot carry the stamp.
*/
export function markAuthSeamEscape<T>(error: T): T {
if ((typeof error === 'object' && error !== null) || typeof error === 'function') {
try {
Object.defineProperty(error, AUTH_SEAM, { value: true, configurable: true });
} catch {
// Frozen/sealed: leave unstamped.
}
}
return error;
}

/** Whether `error` escaped through a transport auth seam. */
export function isAuthSeamEscape(error: unknown): boolean {
return (
((typeof error === 'object' && error !== null) || typeof error === 'function') &&
(error as Record<PropertyKey, unknown>)[AUTH_SEAM] === true
);
}
Loading
Loading