Skip to content

Commit faa7e2b

Browse files
fix(client): treat HTTP 401/403 on the negotiation probe as auth failures, not legacy evidence (#2564)
1 parent a9835b4 commit faa7e2b

20 files changed

Lines changed: 909 additions & 91 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@modelcontextprotocol/client': patch
3+
---
4+
5+
The version-negotiation probe no longer misclassifies auth-protected or
6+
failing servers as legacy. Auth status is never era evidence: a 401 or 403
7+
rejection of the `server/discover` probe now surfaces as a typed
8+
authorization failure — an `SdkHttpError` with code `ClientHttpAuthentication`
9+
(401) or `ClientHttpForbidden` (403), carrying the HTTP status, reason
10+
phrase, and response text — instead of triggering the legacy `initialize`
11+
fallback (which put a doomed `initialize` on the wire) or, under `pin` mode,
12+
the false "server did not offer pinned protocol version" diagnostic. The
13+
codes are deliberately not `EraNegotiationFailed`, so era-recovery flows
14+
keyed on that code cannot persist a verdict for an unauthorized exchange. A
15+
5xx rejecting the probe is a server failure and now also rejects typed
16+
(`SdkHttpError(EraNegotiationFailed)`) instead of demoting a mid-deploy
17+
modern server to legacy — the legacy fallback now fires only on the 4xx
18+
shapes the spec licenses.
19+
20+
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
21+
escapes it propagates unchanged, identity intact: the HTTP transports stamp
22+
errors at their auth seams (the `token()` read, `onUnauthorized` including
23+
custom callbacks, the 403 step-up flow, and their own auth-failure
24+
constructions), so `UnauthorizedError` for `finishAuth()`, the flow's typed
25+
failures (`OAuthError`, `InsufficientScopeError`, the
26+
401-after-re-authentication diagnostic), and even an untyped `TypeError`
27+
thrown inside the flow all reach the caller as thrown — never rewrapped,
28+
never consumed by the probe's browser CORS heuristic as legacy-era evidence.

docs/advanced/gateway.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,8 @@ re-probed: 2026-07-28
151151

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

154+
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.
155+
154156
## Skip the probe for a known-legacy server
155157

156158
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.

docs/clients/oauth.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ try {
2929
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.
3030

3131
::: info
32-
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).
32+
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).
3333
:::
3434

3535
## Implement OAuthClientProvider

docs/migration/support-2026-07-28.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,15 @@ infrastructure problems. Anything the probe does not positively recognize as mod
7171
falls back to the legacy era — provided the supported-versions list still contains a
7272
2025-era revision; with a modern-only list `connect()` rejects with
7373
`SdkError(EraNegotiationFailed)` instead. A network outage rejects with a typed connect
74-
error. Probe timeouts are **transport-aware**: on **stdio** a server that does not
74+
error. Auth statuses are another exception: an HTTP `401` or `403` rejecting the probe
75+
is never era evidence — `connect()` rejects with a typed authorization failure (an
76+
`SdkHttpError` with code `ClientHttpAuthentication`/`ClientHttpForbidden` naming the
77+
status, or the transport auth flow's own error propagated unchanged) instead of
78+
falling back — see [Protocol versions](../protocol-versions.md). This holds even for
79+
a front that answers the probe `403` but would pass `initialize`: relying on a legacy
80+
fallback there is a deliberate non-goal — fix the auth wall (or pass credentials),
81+
because auth status never selects an era. A `5xx` on the probe is a server failure,
82+
also never era evidence: `connect()` rejects with `SdkHttpError(EraNegotiationFailed)`. Probe timeouts are **transport-aware**: on **stdio** a server that does not
7583
answer within `timeoutMs` is treated as legacy and the client falls back to `initialize`
7684
(some legacy servers never respond to unknown pre-`initialize`
7785
requests at all); on **HTTP** a probe timeout rejects with `SdkError(RequestTimeout)`

docs/migration/upgrade-to-v2.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1150,11 +1150,11 @@ try {
11501150
}
11511151
```
11521152
1153-
One qualification: this direct `instanceof` check applies under the default `'legacy'`
1154-
version negotiation. Under the probing modes (`versionNegotiation: { mode: 'auto' }`,
1155-
with or without a pin) the connect-time 401 currently surfaces wrapped as
1156-
`SdkError(SdkErrorCode.EraNegotiationFailed)` with the `UnauthorizedError` at
1157-
`error.data.cause` — unwrap before the check, as shown in the
1153+
This direct `instanceof` check works in every version-negotiation mode: under the
1154+
probing modes (`versionNegotiation: { mode: 'auto' }`, with or without a pin) the
1155+
connect-time `UnauthorizedError` also propagates unchanged from `connect()`. Older
1156+
releases wrapped it as `SdkError(SdkErrorCode.EraNegotiationFailed)` with the error at
1157+
`error.data.cause` — that unwrap is no longer needed. See the
11581158
[client OAuth guide](../clients/oauth.md).
11591159
11601160
#### `auth()` options are now `AuthOptions`

docs/protocol-versions.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,8 @@ const cli = new Client(
101101

102102
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.
103103

104+
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.
105+
104106
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.
105107

106108
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)`.

docs/troubleshooting.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ With the global in place the [client OAuth](./clients/oauth.md) flows run unchan
7575
- `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'`.
7676
- `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.
7777
- `Version negotiation probe failed: ...` — the probe hit a transport failure (network outage, HTTP connection drop): fix connectivity and retry.
78+
- `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.
79+
80+
A `401`/`403` probe rejection is **not** this code — see the next section.
7881

7982
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):
8083

@@ -112,6 +115,12 @@ legacy
112115

113116
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.
114117

118+
## `Version negotiation failed: the server requires authorization (HTTP 401)`
119+
120+
`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).
121+
122+
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.)
123+
115124
## `SdkError: METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION`
116125

117126
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.

examples/cli-client/host/host.ts

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,13 @@ import {
1717
Client,
1818
LOG_LEVEL_META_KEY,
1919
ProtocolError,
20-
SdkError,
2120
StreamableHTTPClientTransport,
2221
SUPPORTED_PROTOCOL_VERSIONS,
2322
UnauthorizedError
2423
} from '@modelcontextprotocol/client';
2524
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
2625

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

103-
function unwrapUnauthorized(error: unknown): UnauthorizedError | undefined {
104-
if (error instanceof UnauthorizedError) return error;
105-
// Under versionNegotiation 'auto', a connect-time 401 surfaces as
106-
// SdkError(EraNegotiationFailed) with the UnauthorizedError in error.data.cause.
107-
if (error instanceof SdkError && isRecord(error.data) && error.data.cause instanceof UnauthorizedError) {
108-
return error.data.cause;
109-
}
110-
return undefined;
111-
}
112-
113101
function samplingContentToParts(content: CreateMessageRequest['params']['messages'][number]['content']): ContentPart[] {
114102
const blocks = Array.isArray(content) ? content : [content];
115103
const parts: ContentPart[] = [];
@@ -488,7 +476,9 @@ export class McpHost {
488476
try {
489477
await client.connect(httpTransport);
490478
} catch (error) {
491-
if (!unwrapUnauthorized(error)) throw error;
479+
// A connect-time 401 propagates UnauthorizedError unchanged
480+
// in every negotiation mode, including the probing ones.
481+
if (!(error instanceof UnauthorizedError)) throw error;
492482
const finishTransport = httpTransport;
493483
const authorized = await completeAuthorizationWithBrowser({
494484
serverName: name,
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/**
2+
* Auth-seam provenance stamp (internal — not part of the public API).
3+
*
4+
* Errors escaping a transport auth seam — the `authProvider.token()` read,
5+
* an `onUnauthorized` invocation (SDK adapter or custom user callback), the
6+
* 403 step-up flow, the transport's own auth-failure constructions — are
7+
* stamped at the throw boundary. The version-negotiation probe routes stamped
8+
* errors as auth outcomes; provenance is recorded where it is known, never
9+
* reconstructed from error types downstream.
10+
*
11+
* `Symbol.for` uses the global symbol registry, so the stamp survives a
12+
* duplicated SDK copy in one process (bundler double-install, version skew)
13+
* by design: both copies resolve the same symbol.
14+
*/
15+
const AUTH_SEAM = Symbol.for('mcp.authSeamEscape');
16+
17+
/**
18+
* Stamp `error` as an auth-seam escape and return it — identity-preserving
19+
* (the same object flows on, `instanceof` and `.cause` chains intact). A
20+
* frozen/sealed object is returned unstamped rather than replaced: identity
21+
* outranks provenance. Primitive throws cannot carry the stamp.
22+
*/
23+
export function markAuthSeamEscape<T>(error: T): T {
24+
if ((typeof error === 'object' && error !== null) || typeof error === 'function') {
25+
try {
26+
Object.defineProperty(error, AUTH_SEAM, { value: true, configurable: true });
27+
} catch {
28+
// Frozen/sealed: leave unstamped.
29+
}
30+
}
31+
return error;
32+
}
33+
34+
/** Whether `error` escaped through a transport auth seam. */
35+
export function isAuthSeamEscape(error: unknown): boolean {
36+
return (
37+
((typeof error === 'object' && error !== null) || typeof error === 'function') &&
38+
(error as Record<PropertyKey, unknown>)[AUTH_SEAM] === true
39+
);
40+
}

0 commit comments

Comments
 (0)