Skip to content

Commit bc4ce30

Browse files
fix(client): treat HTTP 401/403 on the negotiation probe as auth failures, not legacy evidence
A 401 (without an authProvider) or 403 rejection of the connect-time server/discover probe fell into the classifier's conservative legacy fallback: auto mode sent a doomed legacy initialize, and pin mode reported "the server did not offer pinned protocol version ..." for a server that was never asked. Auth status is not era evidence. classifyHttpError now has an explicit 401/403 row ahead of the JSON-RPC body parse: a typed SdkHttpError (EraNegotiationFailed) carrying the HTTP status, reason phrase, and response text — never a legacy verdict. The probe's send-error normalization also propagates the transport auth flow's own typed failures unchanged (UnauthorizedError for finishAuth, OAuthError such as invalid_grant from a failed token refresh, InsufficientScopeError and the OAuthClientFlowError family, the 401-after-re-authentication diagnostic) instead of flattening them into the generic HTTP row. Untyped errors escaping the SDK's own OAuth flow (e.g. a fetch TypeError from the dynamic-client-registration POST) are wrapped as typed SdkErrors (ClientHttpAuthentication / ClientHttpForbidden, the original as cause), so an auth-flow crash can no longer fall into the probe's browser CORS heuristic and read as legacy-era evidence. An e2e requirement pins the common production shape end to end: an OAuth-protected legacy server under mode 'auto' — probe 401'd, auth challenge propagated, finishAuth, reconnect re-probes with the token, the legacy rejection supplies the era evidence, initialize and tools/call succeed. Fixes #2561
1 parent f130e1a commit bc4ce30

16 files changed

Lines changed: 551 additions & 57 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@modelcontextprotocol/client': patch
3+
---
4+
5+
The version-negotiation probe no longer misclassifies auth-protected servers
6+
(HTTP 401/403) as legacy. A 401 or 403 rejection of the `server/discover`
7+
probe now surfaces as a typed authorization failure — an `SdkHttpError` with
8+
code `EraNegotiationFailed` carrying the HTTP status, reason phrase, and
9+
response text (a 403 `insufficient_scope` challenge instead surfaces the
10+
flow's typed `InsufficientScopeError`) — instead of triggering the legacy
11+
`initialize` fallback (which put a doomed `initialize` on the wire) or,
12+
under `pin` mode, the false "server did not offer pinned protocol version"
13+
diagnostic. With an `authProvider` the transport's auth flow still runs
14+
first and its outcome now always propagates unchanged: `UnauthorizedError`
15+
for `finishAuth()` as before, and the flow's own typed failures
16+
(`OAuthError` such as `invalid_grant` from a failed token refresh,
17+
`InsufficientScopeError`, the 401-after-re-authentication diagnostic)
18+
instead of a legacy verdict or a generic negotiation wrap. Untyped errors
19+
escaping the SDK's own OAuth flow (e.g. a fetch `TypeError` from the
20+
dynamic-client-registration POST) are now wrapped as `SdkError` with code
21+
`ClientHttpAuthentication` (401 flow) or `ClientHttpForbidden` (step-up flow)
22+
and the original error as `cause`, so an auth-flow crash is never misread as
23+
probe-level network evidence — previously, in a browser, such a `TypeError`
24+
fell into the probe's CORS heuristic and produced a legacy-era verdict.

docs/clients/oauth.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
shape: how-to
33
description: 'Sign an end user in from a client you build with the OAuth authorization-code flow.'
44
---
5+
56
# Authenticate a user with OAuth
67

78
Protecting a server you run → [Require authorization](../serving/authorization.md). Signing a user in from a client → this page. No user present → [Authenticate without a user](./machine-auth.md).
@@ -29,7 +30,7 @@ try {
2930
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.
3031

3132
::: 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).
33+
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).
3334
:::
3435

3536
## Implement OAuthClientProvider

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,11 @@ infrastructure problems. Anything the probe does not positively recognize as mod
7676
falls back to the legacy era — provided the supported-versions list still contains a
7777
2025-era revision; with a modern-only list `connect()` rejects with
7878
`SdkError(EraNegotiationFailed)` instead. A network outage rejects with a typed connect
79-
error. Probe timeouts are **transport-aware**: on **stdio** a server that does not
79+
error. Auth statuses are another exception: an HTTP `401` or `403` rejecting the probe
80+
is never era evidence — `connect()` rejects with a typed authorization failure (an
81+
`SdkHttpError(EraNegotiationFailed)` naming the status, or the transport auth flow's
82+
own typed error propagated unchanged) instead of falling back — see
83+
[Protocol versions](../protocol-versions.md). Probe timeouts are **transport-aware**: on **stdio** a server that does not
8084
answer within `timeoutMs` is treated as legacy and the client falls back to `initialize`
8185
(some legacy servers never respond to unknown pre-`initialize`
8286
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
@@ -1151,11 +1151,11 @@ try {
11511151
}
11521152
```
11531153
1154-
One qualification: this direct `instanceof` check applies under the default `'legacy'`
1155-
version negotiation. Under the probing modes (`versionNegotiation: { mode: 'auto' }`,
1156-
with or without a pin) the connect-time 401 currently surfaces wrapped as
1157-
`SdkError(SdkErrorCode.EraNegotiationFailed)` with the `UnauthorizedError` at
1158-
`error.data.cause` — unwrap before the check, as shown in the
1154+
This direct `instanceof` check works in every version-negotiation mode: under the
1155+
probing modes (`versionNegotiation: { mode: 'auto' }`, with or without a pin) the
1156+
connect-time `UnauthorizedError` also propagates unchanged from `connect()`. Older
1157+
releases wrapped it as `SdkError(SdkErrorCode.EraNegotiationFailed)` with the error at
1158+
`error.data.cause` — that unwrap is no longer needed. See the
11591159
[client OAuth guide](../clients/oauth.md).
11601160
11611161
#### `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. With no `authProvider`, `connect()` rejects with an `SdkHttpError(EraNegotiationFailed)` naming the status — except a `403` whose `WWW-Authenticate` challenge carries `error="insufficient_scope"`, where the transport's step-up handling (not gated on a provider) rejects with the flow's typed `InsufficientScopeError` instead. With a provider, the auth flow runs first and its outcome propagates unchanged — `UnauthorizedError` for `finishAuth()`, or the flow's own typed failure (an `OAuthError` such as `invalid_grant` from a failed token refresh, an `InsufficientScopeError`, the 401-after-re-authentication diagnostic). 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)`.

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,

packages/client/src/client/auth.ts

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,19 @@ import {
2525
OAuthTokensSchema,
2626
OpenIdProviderDiscoveryMetadataSchema,
2727
resourceUrlFromServerUrl,
28+
SdkError,
29+
SdkErrorCode,
2830
stampErrorBrands
2931
} from '@modelcontextprotocol/core-internal';
3032
import pkceChallenge from 'pkce-challenge';
3133

32-
import { AuthorizationServerMismatchError, InsecureTokenEndpointError, IssuerMismatchError, RegistrationRejectedError } from './authErrors';
34+
import {
35+
AuthorizationServerMismatchError,
36+
InsecureTokenEndpointError,
37+
IssuerMismatchError,
38+
OAuthClientFlowError,
39+
RegistrationRejectedError
40+
} from './authErrors';
3341

3442
// Re-exported for back-compat — the canonical home is ./authErrors.js.
3543
export { AuthorizationServerMismatchError, InsecureTokenEndpointError, IssuerMismatchError, RegistrationRejectedError } from './authErrors';
@@ -169,6 +177,30 @@ export function isOAuthClientProvider(provider: AuthProvider | OAuthClientProvid
169177
return typeof p.tokens === 'function' && typeof p.clientInformation === 'function';
170178
}
171179

180+
/**
181+
* Keep auth-flow escapes recognizable as auth-flow failures: typed auth errors
182+
* rethrow unchanged, while anything untyped thrown from inside {@linkcode auth}
183+
* (e.g. a fetch `TypeError` from the dynamic-client-registration POST) is
184+
* wrapped as an {@linkcode SdkError} with `code` and the original error as
185+
* `data.cause`. Downstream classifiers — the version-negotiation probe's
186+
* network-error row in particular, whose browser heuristic reads an opaque
187+
* `TypeError` as legacy-era evidence — must never mistake an auth-flow crash
188+
* for a transport-level network failure.
189+
*/
190+
export function rethrowTypedAuthFlowError(error: unknown, code: SdkErrorCode): never {
191+
if (
192+
error instanceof UnauthorizedError ||
193+
error instanceof OAuthError ||
194+
error instanceof OAuthClientFlowError ||
195+
error instanceof SdkError
196+
) {
197+
throw error;
198+
}
199+
throw new SdkError(code, `OAuth authorization flow failed: ${error instanceof Error ? error.message : String(error)}`, {
200+
cause: error
201+
});
202+
}
203+
172204
/**
173205
* Standard `onUnauthorized` behavior for OAuth providers: extracts
174206
* `WWW-Authenticate` parameters from the 401 response and runs {@linkcode auth}.
@@ -180,13 +212,18 @@ export async function handleOAuthUnauthorized(
180212
extraAuthOptions?: Pick<AuthOptions, 'skipIssuerMetadataValidation'>
181213
): Promise<void> {
182214
const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(ctx.response);
183-
const result = await auth(provider, {
184-
serverUrl: ctx.serverUrl,
185-
resourceMetadataUrl,
186-
scope,
187-
fetchFn: ctx.fetchFn,
188-
...extraAuthOptions
189-
});
215+
let result: AuthResult;
216+
try {
217+
result = await auth(provider, {
218+
serverUrl: ctx.serverUrl,
219+
resourceMetadataUrl,
220+
scope,
221+
fetchFn: ctx.fetchFn,
222+
...extraAuthOptions
223+
});
224+
} catch (error) {
225+
rethrowTypedAuthFlowError(error, SdkErrorCode.ClientHttpAuthentication);
226+
}
190227
if (result !== 'AUTHORIZED') {
191228
throw new UnauthorizedError();
192229
}

packages/client/src/client/probeClassifier.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@
77
* sibling), or a typed connect error.
88
*
99
* The classifier is deliberately conservative: anything it does not positively
10-
* recognize as modern resolves to the legacy fallback, and a network outage is a
11-
* typed connect error, never an era verdict. The verdicts apply to the
10+
* recognize as modern resolves to the legacy fallback, and a network outage or
11+
* an auth-status rejection (HTTP 401/403) is a typed connect error, never an
12+
* era verdict. The verdicts apply to the
1213
* negotiation phase only — an established modern connection is never silently
1314
* demoted to `initialize` by a later failure.
1415
*/
@@ -19,6 +20,7 @@ import {
1920
modernProtocolVersions,
2021
SdkError,
2122
SdkErrorCode,
23+
SdkHttpError,
2224
UnsupportedProtocolVersionError
2325
} from '@modelcontextprotocol/core-internal';
2426

@@ -46,10 +48,10 @@ export type ProbeOutcome =
4648
| { kind: 'result'; result: unknown }
4749
/** Answered with a JSON-RPC error (any HTTP status, including 200-bodied errors and stdio in-band errors). */
4850
| { kind: 'rpc-error'; code: number; message: string; data?: unknown }
49-
/** The HTTP layer rejected the probe POST (non-2xx); `body` is the raw response text, when available. */
50-
| { kind: 'http-error'; status: number; body?: string }
51+
/** The HTTP layer rejected the probe POST (non-2xx); `body` is the raw response text and `statusText` the HTTP reason phrase, when available. */
52+
| { kind: 'http-error'; status: number; body?: string; statusText?: string }
5153
| { kind: 'network-error'; error: unknown }
52-
/** The transport's auth flow challenged during the probe send (`UnauthorizedError`). */
54+
/** The transport's auth flow challenged or failed during the probe send (`UnauthorizedError`, an `OAuthClientFlowError`, or the transport's own re-auth/step-up diagnostics) — `error` propagates unchanged. */
5355
| { kind: 'auth-required'; error: Error }
5456
/** The transport reported close while the probe awaited its reply. */
5557
| { kind: 'closed' }
@@ -241,7 +243,24 @@ function classifyRpcError(outcome: { code: number; message: string; data?: unkno
241243
return { kind: 'legacy' };
242244
}
243245

244-
function classifyHttpError(outcome: { status: number; body?: string }, context: ProbeClassifierContext): ProbeVerdict {
246+
function classifyHttpError(outcome: { status: number; body?: string; statusText?: string }, context: ProbeClassifierContext): ProbeVerdict {
247+
// Auth statuses are never era evidence, and a 401/403 body is the auth
248+
// layer's, not the server/discover handler's — this row ranks above the
249+
// JSON-RPC body parse. Parallel to the auth-required row: a typed failure
250+
// naming the status (response text on `data.text`), never a legacy
251+
// fallback. This matches the non-probing legacy-mode client, which
252+
// surfaces the same server as an `SdkHttpError` carrying this status.
253+
if (outcome.status === 401 || outcome.status === 403) {
254+
const detail = outcome.status === 401 ? 'the server requires authorization (HTTP 401)' : 'the server denied access (HTTP 403)';
255+
return {
256+
kind: 'error',
257+
error: new SdkHttpError(SdkErrorCode.EraNegotiationFailed, `Version negotiation failed: ${detail}`, {
258+
status: outcome.status,
259+
statusText: outcome.statusText,
260+
text: outcome.body
261+
})
262+
};
263+
}
245264
// HTTP-rejected probes carry their JSON-RPC error in the response body — classify it like an in-band error.
246265
const rpcError = parseJsonRpcErrorBody(outcome.body);
247266
if (rpcError !== undefined) {

packages/client/src/client/streamableHttp.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
isOAuthClientProvider,
3030
isStrictScopeSuperset,
3131
resolveAuthorizationCallbackParams,
32+
rethrowTypedAuthFlowError,
3233
UnauthorizedError
3334
} from './auth';
3435
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced via {@linkcode} in finishAuth JSDoc
@@ -410,14 +411,20 @@ export class StreamableHTTPClientTransport implements Transport {
410411
// we must force a fresh authorization request.
411412
const forceReauthorization = isStrictScopeSuperset(unionScope, tokens?.scope);
412413

413-
return auth(this._oauthProvider, {
414-
serverUrl: this._url,
415-
resourceMetadataUrl: this._resourceMetadataUrl,
416-
scope: unionScope,
417-
forceReauthorization,
418-
fetchFn: this._fetchWithInit,
419-
skipIssuerMetadataValidation: this._skipIssuerMetadataValidation
420-
});
414+
try {
415+
return await auth(this._oauthProvider, {
416+
serverUrl: this._url,
417+
resourceMetadataUrl: this._resourceMetadataUrl,
418+
scope: unionScope,
419+
forceReauthorization,
420+
fetchFn: this._fetchWithInit,
421+
skipIssuerMetadataValidation: this._skipIssuerMetadataValidation
422+
});
423+
} catch (error) {
424+
// Untyped escapes from the step-up flow stay recognizable as auth
425+
// failures — see rethrowTypedAuthFlowError.
426+
rethrowTypedAuthFlowError(error, SdkErrorCode.ClientHttpForbidden);
427+
}
421428
}
422429

423430
private async _commonHeaders(): Promise<Headers> {

0 commit comments

Comments
 (0)