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
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.
Copy file name to clipboardExpand all lines: docs/advanced/gateway.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
@@ -151,6 +151,8 @@ re-probed: 2026-07-28
151
151
152
152
Replace the persisted blob with the fresh `getDiscoverResult()` and the rest of the fleet recovers on its next read.
153
153
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
+
154
156
## Skip the probe for a known-legacy server
155
157
156
158
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.
Copy file name to clipboardExpand all lines: docs/clients/oauth.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -29,7 +29,7 @@ try {
29
29
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
30
31
31
::: 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).
Copy file name to clipboardExpand all lines: docs/migration/support-2026-07-28.md
+9-1Lines changed: 9 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -71,7 +71,15 @@ infrastructure problems. Anything the probe does not positively recognize as mod
71
71
falls back to the legacy era — provided the supported-versions list still contains a
72
72
2025-era revision; with a modern-only list `connect()` rejects with
73
73
`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
75
83
answer within `timeoutMs` is treated as legacy and the client falls back to `initialize`
76
84
(some legacy servers never respond to unknown pre-`initialize`
77
85
requests at all); on **HTTP** a probe timeout rejects with `SdkError(RequestTimeout)` —
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 — 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
+
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)`.
Copy file name to clipboardExpand all lines: docs/troubleshooting.md
+9Lines changed: 9 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -75,6 +75,9 @@ With the global in place the [client OAuth](./clients/oauth.md) flows run unchan
75
75
-`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'`.
76
76
-`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.
77
77
-`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.
78
81
79
82
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):
80
83
@@ -112,6 +115,12 @@ legacy
112
115
113
116
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.
114
117
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.)
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.
0 commit comments