Skip to content

Commit 573da02

Browse files
committed
fix(client): scope the probe 202 row to probe ids and name the cause on pin/modern-only rejections
Address review round 2 on the completed-exchange fallback: - 202 gate: key the accepted-without-reply row on the probe's reserved server-discover-probe- string id prefix (shared constant in invalidReplySeam), so the public post-connect Client.discover() — a Protocol request with a numeric id — can never surface EraNegotiationFailed mid-session: a 202 answering it stays pending until the ordinary RequestTimeout, as before. The inline comment and the sdkErrors.ts docstring now state the same scoping; wire-real regression test added. - Diagnostic fidelity: the pin and modern-only rejections for outcomes routed to the legacy verdict without a server/discover answer now name the cause, mirroring closedCause ("the server answered the probe with HTTP 503" for http-error outcomes; "the probe reply was not a valid JSON-RPC message" for invalid-reply), and carry the evidence on error.data (status/statusText/response text, or the stamped original error and offending body — the invalid-reply outcome now keeps the original error instead of discarding it). Message content and data carriage pinned by tests; troubleshooting.md remediations updated, including that a 5xx CAN produce ERA_NEGOTIATION_FAILED in pin and modern-only modes. - Docs overbreadth: qualify the completed-exchange rule on protocol-versions.md and support-2026-07-28.md to answers the client can read as a failure, dropping the unconditional "however broken" — a direct 2xx JSON answer that parses and validates cleanly yet carries no reply to the probe (an empty batch, or a schema-valid reply under a non-matching id) reads as silence and runs out the probe timeout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013EfMZraQUoWyeSBT2HaQ5v
1 parent b226ba9 commit 573da02

10 files changed

Lines changed: 193 additions & 34 deletions

File tree

.changeset/rude-servers-fall-back.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@
1010
- a `202 Accepted` answer to the probe — now immediate legacy evidence instead of waiting out the full probe timeout;
1111
- any `5xx` answer, with or without a JSON-RPC error body (some deployments map JSON-RPC errors to 500). Hosts that cache era verdicts for `connect({ prior })` should date cached legacy verdicts — a 5xx can be a modern server's transient failure; the SDK itself never persists a verdict.
1212

13-
Unchanged: 401/403 remain typed auth failures (never era evidence), network-level failures and an HTTP probe timeout still reject with typed errors, and `pin` mode still never falls back.
13+
Unchanged: 401/403 remain typed auth failures (never era evidence), network-level failures and an HTTP probe timeout still reject with typed errors, and `pin` mode still never falls back. In `pin` mode — and for modern-only clients — the typed `ERA_NEGOTIATION_FAILED` rejection for these newly-legacy outcomes now names the concrete cause in its message (the HTTP status, or the invalid reply) and carries the evidence on `error.data` (status/statusText/response text, or the offending body and original validation error), so a transient 5xx stays distinguishable from a server that genuinely lacks the pinned revision.

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,10 @@ Failure semantics under `'auto'` are deliberately conservative but never silent
7070
infrastructure problems. Anything the probe does not positively recognize as modern
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
73-
`SdkError(EraNegotiationFailed)` instead. In particular, any COMPLETED non-auth HTTP
74-
exchange whose direct (non-SSE) answer is not a valid modern reply is legacy evidence
73+
`SdkError(EraNegotiationFailed)` instead. In particular, a COMPLETED non-auth HTTP
74+
exchange whose direct (non-SSE) answer the client can read as a failure — an
75+
HTTP-level rejection or acceptance-without-reply, or a body failing JSON-RPC parsing
76+
or validation — is legacy evidence
7577
and falls back to `initialize`: a `2xx` carrying a JSON-RPC error body the strict
7678
schema rejects (e.g. the JSON-RPC 2.0 parse-error shape — `-32700` with `id: null`
7779
some deployed servers send for unknown methods), an empty or unparseable body, a
@@ -81,7 +83,9 @@ should date legacy verdicts — a `5xx` can be a modern server's transient failu
8183
SDK itself never persists a verdict). A probe answered `200` + `text/event-stream` is
8284
classified by the JSON-RPC reply the stream delivers instead: an SSE stream carrying
8385
only a schema-invalid reply, or ending without one, reads as silence and still runs
84-
out the probe timeout, as before. A network outage rejects with a typed connect
86+
out the probe timeout, as before — as does a direct `2xx` JSON answer that parses and
87+
validates cleanly yet carries no reply to the probe (an empty batch, or a schema-valid
88+
reply under a request id that is not the probe's). A network outage rejects with a typed connect
8589
error. Auth statuses are another exception: an HTTP `401` or `403` rejecting the probe
8690
is never era evidence — `connect()` rejects with a typed authorization failure (an
8791
`SdkHttpError` with code `ClientHttpAuthentication`/`ClientHttpForbidden` naming the

docs/protocol-versions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ 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-
A server that completes the exchange is classified by what it answers, however broken. Any completed non-auth HTTP exchange whose direct (non-SSE) answer is not a valid modern reply is legacy evidence and `connect()` falls back to `initialize`: a `2xx` carrying a JSON-RPC error the strict schema rejects (such as the JSON-RPC 2.0 parse-error shape `{"error":{"code":-32700,...},"id":null}` some deployed servers send for the unknown probe), an empty or unparseable body, a non-MCP content type like a proxy's HTML error page, a `202 Accepted` that will never carry a reply (immediate fallback — a `202` never waits out the probe timeout), any unrecognized `4xx` rejection, and any `5xx` (some deployments map JSON-RPC errors to `500`; a genuinely failing server just fails the fallback `initialize` with its own error). Only auth statuses, network failures, and silence reject without trying `initialize`, below. The rule is about answers delivered as a completed HTTP body: a probe answered `200` + `text/event-stream` is classified by the JSON-RPC reply the stream delivers, so an SSE stream that carries only a schema-invalid reply — or ends without any reply — reads as silence and still runs out the probe timeout, exactly as before (modern servers legitimately answer the probe over SSE, so a malformed frame mid-stream is not era evidence). One caveat for hosts that cache era verdicts via `connect({ prior })`: a `5xx` can also be a _modern_ server having a transient failure, so date cached legacy verdicts and let them expire (see the [gateway guide](./advanced/gateway.md)) — the SDK itself never persists a verdict.
104+
A server that completes the exchange is classified by what it answers — provided the answer is one the client can read as a failure: an HTTP-level rejection or acceptance-without-reply, or a body that fails JSON-RPC parsing or validation. Any completed non-auth HTTP exchange whose direct (non-SSE) answer fails in one of those ways is legacy evidence and `connect()` falls back to `initialize`: a `2xx` carrying a JSON-RPC error the strict schema rejects (such as the JSON-RPC 2.0 parse-error shape `{"error":{"code":-32700,...},"id":null}` some deployed servers send for the unknown probe), an empty or unparseable body, a non-MCP content type like a proxy's HTML error page, a `202 Accepted` that will never carry a reply (immediate fallback — a `202` never waits out the probe timeout), any unrecognized `4xx` rejection, and any `5xx` (some deployments map JSON-RPC errors to `500`; a genuinely failing server just fails the fallback `initialize` with its own error). Only auth statuses, network failures, and silence reject without trying `initialize`, below. The rule is about answers delivered as a completed HTTP body: a probe answered `200` + `text/event-stream` is classified by the JSON-RPC reply the stream delivers, so an SSE stream that carries only a schema-invalid reply — or ends without any reply — reads as silence and still runs out the probe timeout, exactly as before (modern servers legitimately answer the probe over SSE, so a malformed frame mid-stream is not era evidence). The rare direct `application/json` answers that parse and validate cleanly yet carry no reply to the probe — an empty batch (`[]`), or a schema-valid reply under a request id that is not the probe's — read as silence the same way and also run out the probe timeout. One caveat for hosts that cache era verdicts via `connect({ prior })`: a `5xx` can also be a _modern_ server having a transient failure, so date cached legacy verdicts and let them expire (see the [gateway guide](./advanced/gateway.md)) — the SDK itself never persists a verdict.
105105

106106
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. 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.
107107

docs/troubleshooting.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,14 @@ With the global in place the [client OAuth](./clients/oauth.md) flows run unchan
6969

7070
`connect()` found no **protocol era** both sides speak, or the negotiation probe was cut short. Match the message tail:
7171

72-
- `the server did not offer pinned protocol version ... via server/discover (no fallback in pin mode)` — the pin names a revision the server does not offer, and pinning never falls back: drop the pin or use `'auto'`.
72+
- `the server did not offer pinned protocol version ... via server/discover (no fallback in pin mode)` — the pin names a revision the server does not offer, and pinning never falls back: drop the pin or use `'auto'`. When the parenthetical names a cause — `(the server answered the probe with HTTP 503; no fallback in pin mode)` or `(the probe reply was not a valid JSON-RPC message; no fallback in pin mode)` — the server or a proxy in front of it failed or answered garbage rather than declining the pin: `error.data` carries the evidence (HTTP status and response text, or the offending body and original validation error), so fix the deployment and retry before touching the pin.
7373
- `the connection closed during the server/discover probe before the server offered pinned protocol version ...` — same pin, but the server exited on the probe (an exit-on-probe legacy server): use `'auto'`.
74-
- `the server gave no modern evidence and this client supports no pre-2026-07-28 protocol version to fall back to` — or its `the connection closed during the server/discover probe and this client supports no ...` variant — `mode: 'auto'` with a modern-only `supportedProtocolVersions` list removes the legacy fallback: restore a pre-2026 entry.
74+
- `the server gave no modern evidence and this client supports no pre-2026-07-28 protocol version to fall back to` — or its `the connection closed during the server/discover probe and this client supports no ...` variant — `mode: 'auto'` with a modern-only `supportedProtocolVersions` list removes the legacy fallback: restore a pre-2026 entry. A parenthetical after `no modern evidence` names a concrete probe failure (`the server answered the probe with HTTP 503`, `the probe reply was not a valid JSON-RPC message`) with the evidence on `error.data` — for those, also check that the server is healthy.
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.
7878

79-
A `5xx` probe answer does **not** produce this code: a completed server-error exchange is legacy evidence, so `connect()` falls back to the legacy `initialize` (a genuinely failing server then fails that fallback with its own error). Note that a `5xx` can also be a **modern** server's transient failure, so hosts caching era verdicts via `connect({ prior })` should date cached legacy verdicts and let them expire — see [Protocol versions](./protocol-versions.md); the SDK itself never persists a verdict.
79+
In `'auto'` mode with the legacy fallback available, a `5xx` probe answer does **not** produce this code: a completed server-error exchange is legacy evidence, so `connect()` falls back to the legacy `initialize` (a genuinely failing server then fails that fallback with its own error). In `pin` mode — and for a modern-only client — the same `5xx` (or a broken probe reply) **does** surface as this code, with the cause named in the message and the evidence carried on `error.data`, as in the bullets above. Note that a `5xx` can also be a **modern** server's transient failure, so hosts caching era verdicts via `connect({ prior })` should date cached legacy verdicts and let them expire — see [Protocol versions](./protocol-versions.md); the SDK itself never persists a verdict.
8080

8181
A `401`/`403` probe rejection is **not** this code either — see the next section.
8282

packages/client/src/client/invalidReplySeam.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@
1919
*/
2020
const INVALID_REPLY = Symbol.for('mcp.invalidReplyBody');
2121

22+
/**
23+
* Reserved prefix of the connect-time `server/discover` probe's string request
24+
* ids (minted by the negotiation engine's ProbeWindow — the probe uses string
25+
* ids and never consumes Protocol's numeric ids). Transport behavior scoped to
26+
* the probe exchange — the Streamable HTTP 202-accepted stamp site — keys on
27+
* this prefix, so the public post-connect `Client.discover()` request (a
28+
* Protocol request with a numeric id) can never match it.
29+
*/
30+
export const SERVER_DISCOVER_PROBE_ID_PREFIX = 'server-discover-probe-';
31+
2232
/**
2333
* Stamp `error` as an invalid-reply escape (with the reply body that failed
2434
* validation, when one was parsed) and return it — identity-preserving (the

packages/client/src/client/probeClassifier.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,11 @@ export type ProbeOutcome =
5454
* (`body` carries the offending parsed value — e.g. an error reply with
5555
* `id: null` or unknown members), or a bodiless failure (`body` absent) —
5656
* an empty/unparseable body, a non-MCP content type such as an HTML error
57-
* page, or a 202 accepted-without-reply.
57+
* page, or a 202 accepted-without-reply. `cause` carries the original
58+
* error the transport stamped at its parse boundary, so the modes that
59+
* cannot fall back (pin, modern-only) can surface it as diagnostics.
5860
*/
59-
| { kind: 'invalid-reply'; body?: unknown }
61+
| { kind: 'invalid-reply'; body?: unknown; cause?: unknown }
6062
/** The HTTP layer rejected the probe POST (non-2xx); `body` is the raw response text and `statusText` the HTTP reason phrase, when available. */
6163
| { kind: 'http-error'; status: number; body?: string; statusText?: string }
6264
| { kind: 'network-error'; error: unknown }

packages/client/src/client/streamableHttp.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import {
3535
import type { IssuerMismatchError } from './authErrors';
3636
import { InsufficientScopeError } from './authErrors';
3737
import { markAuthSeamEscape } from './authSeam';
38-
import { markInvalidReplyEscape } from './invalidReplySeam';
38+
import { markInvalidReplyEscape, SERVER_DISCOVER_PROBE_ID_PREFIX } from './invalidReplySeam';
3939

4040
/** Default cap on step-up re-authorization retries within a single send/stream-open. */
4141
const DEFAULT_MAX_STEP_UP_RETRIES = 1;
@@ -1130,9 +1130,19 @@ export class StreamableHTTPClientTransport implements Transport {
11301130
// exchange that will never produce a reply (the spec reserves
11311131
// 202 for notifications and responses): surface it immediately,
11321132
// stamped for the probe's classifier, instead of letting the
1133-
// probe wait out its full timeout. Scoped to the probe request
1134-
// — every other flow through this branch is unchanged.
1135-
if (!Array.isArray(message) && isJSONRPCRequest(message) && message.method === 'server/discover') {
1133+
// probe wait out its full timeout. Scoped to the connect-time
1134+
// probe by its reserved string id prefix (the probe never uses
1135+
// Protocol's numeric ids), so the public post-connect
1136+
// `Client.discover()` request can never match — a 202 answering
1137+
// it stays pending until the ordinary request timeout, and
1138+
// every other flow through this branch is unchanged.
1139+
if (
1140+
!Array.isArray(message) &&
1141+
isJSONRPCRequest(message) &&
1142+
message.method === 'server/discover' &&
1143+
typeof message.id === 'string' &&
1144+
message.id.startsWith(SERVER_DISCOVER_PROBE_ID_PREFIX)
1145+
) {
11361146
throw markInvalidReplyEscape(
11371147
new SdkError(
11381148
SdkErrorCode.EraNegotiationFailed,

0 commit comments

Comments
 (0)