You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
Copy file name to clipboardExpand all lines: docs/clients/oauth.md
+2-1Lines changed: 2 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -2,6 +2,7 @@
2
2
shape: how-to
3
3
description: 'Sign an end user in from a client you build with the OAuth authorization-code flow.'
4
4
---
5
+
5
6
# Authenticate a user with OAuth
6
7
7
8
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 {
29
30
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.
30
31
31
32
::: 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).
Copy file name to clipboardExpand all lines: docs/protocol-versions.md
+2Lines changed: 2 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -101,6 +101,8 @@ const cli = new Client(
101
101
102
102
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.
103
103
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
+
104
106
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.
105
107
106
108
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)`.
/** 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. */
53
55
|{kind: 'auth-required';error: Error}
54
56
/** The transport reported close while the probe awaited its reply. */
0 commit comments