fix(client): treat HTTP 401/403 on the negotiation probe as auth failures, not legacy evidence (#2564)
This commit is contained in:
parent
a9835b4149
commit
faa7e2b470
6 changed files with 909 additions and 91 deletions
|
|
@ -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.
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)` —
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
#### `auth()` options are now `AuthOptions`
|
||||
|
|
|
|||
|
|
@ -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)`.
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue