Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/rude-servers-fall-back.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@modelcontextprotocol/client': patch
---

`versionNegotiation: { mode: 'auto' }` now falls back to the classic `initialize` handshake whenever the `server/discover` probe gets a completed, non-auth HTTP answer that is not a valid modern reply, instead of failing `connect()`. Newly falling back:

- a 2xx reply whose JSON body fails strict JSON-RPC validation — e.g. the JSON-RPC 2.0 parse-error shape `{"error":{"code":-32700,...},"id":null}` some deployed servers send for unknown methods (anomalyco/opencode#39354), or error replies with extra/unknown members;
- a 2xx `application/json` reply with an empty or unparseable body;
- a 2xx reply in a non-MCP content type (e.g. a proxy's HTML error page, `text/plain`, or a missing content-type);
- a `202 Accepted` answer to the probe — now immediate legacy evidence instead of waiting out the full probe timeout;
- 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.

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.
20 changes: 17 additions & 3 deletions docs/migration/support-2026-07-28.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,30 @@ Failure semantics under `'auto'` are deliberately conservative but never silent
infrastructure problems. Anything the probe does not positively recognize as modern
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
`SdkError(EraNegotiationFailed)` instead. In particular, a COMPLETED non-auth HTTP
exchange whose direct (non-SSE) answer the client can read as a failure — an
HTTP-level rejection or acceptance-without-reply, or a body failing JSON-RPC parsing
or validation — is legacy evidence
and falls back to `initialize`: a `2xx` carrying a JSON-RPC error body the strict
schema rejects (e.g. the JSON-RPC 2.0 parse-error shape — `-32700` with `id: null` —
some deployed servers send for unknown methods), an empty or unparseable body, a
non-MCP content type, a `202` accepted-without-reply (immediate fallback, no
probe-timeout wait), any unrecognized `4xx`, and any `5xx` (hosts caching era verdicts
should date legacy verdicts — a `5xx` can be a modern server's transient failure; the
SDK itself never persists a verdict). A probe answered `200` + `text/event-stream` is
classified by the JSON-RPC reply the stream delivers instead: an SSE stream carrying
only a schema-invalid reply, or ending without one, reads as silence and still runs
out the probe timeout, as before — as does 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 request id that is not the probe's). A network outage rejects with a typed connect
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
because auth status never selects an era. 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)` —
Expand Down
4 changes: 3 additions & 1 deletion docs/protocol-versions.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@ 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.
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.

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.

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.

Expand Down
9 changes: 5 additions & 4 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,16 @@ With the global in place the [client OAuth](./clients/oauth.md) flows run unchan

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

- `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'`.
- `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.
- `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'`.
- `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.
- `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.
- `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.
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.

A `401`/`403` probe rejection is **not** this code either — 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):

Expand Down
63 changes: 63 additions & 0 deletions packages/client/src/client/invalidReplySeam.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* Invalid-reply provenance stamp (internal — not part of the public API).
*
* When the HTTP layer COMPLETES an exchange (2xx) but what came back is not a
* valid JSON-RPC reply — a JSON body the strict message schema rejects, an
* empty or unparseable body, or a 202 accepted-without-reply to the probe —
* the transport stamps the error at the throw boundary, carrying the offending
* body when one was parsed. The version-negotiation probe reads the stamp to
* classify the reply as era evidence — deployed servers answer the unknown
* `server/discover` probe with off-spec shapes such as the JSON-RPC 2.0
* parse-error reply `{"error":{"code":-32700,...},"id":null}` — instead of
* misreporting a server that answered as a network failure. As with the auth
* seam, provenance is recorded where it is known, never reconstructed from
* error types downstream.
*
* `Symbol.for` uses the global symbol registry, so the stamp survives a
* duplicated SDK copy in one process (bundler double-install, version skew)
* by design: both copies resolve the same symbol.
*/
const INVALID_REPLY = Symbol.for('mcp.invalidReplyBody');

/**
* Reserved prefix of the connect-time `server/discover` probe's string request
* ids (minted by the negotiation engine's ProbeWindow — the probe uses string
* ids and never consumes Protocol's numeric ids). Transport behavior scoped to
* the probe exchange — the Streamable HTTP 202-accepted stamp site — keys on
* this prefix, so the public post-connect `Client.discover()` request (a
* Protocol request with a numeric id) can never match it.
*/
export const SERVER_DISCOVER_PROBE_ID_PREFIX = 'server-discover-probe-';

/**
* Stamp `error` as an invalid-reply escape (with the reply body that failed
* validation, when one was parsed) and return it — identity-preserving (the
* same object flows on, `instanceof` and `.cause` chains intact). A
* frozen/sealed object is returned unstamped rather than replaced: identity
* outranks provenance. Primitive throws cannot carry the stamp.
*/
export function markInvalidReplyEscape<T>(error: T, body: unknown): T {
if ((typeof error === 'object' && error !== null) || typeof error === 'function') {
try {
Object.defineProperty(error, INVALID_REPLY, { value: { body }, configurable: true });
} catch {
// Frozen/sealed: leave unstamped.
}
}
return error;
}

/**
* Read the reply body an invalid-reply escape was stamped with, or `undefined`
* when `error` carries no stamp. The `{ body }` wrapper keeps a stamped
* absent/`null` body distinguishable from an absent stamp.
*/
export function readInvalidReplyEscape(error: unknown): { body: unknown } | undefined {
if (((typeof error === 'object' && error !== null) || typeof error === 'function') && INVALID_REPLY in error) {
const stamp = (error as Record<PropertyKey, unknown>)[INVALID_REPLY];
if (typeof stamp === 'object' && stamp !== null && 'body' in stamp) {
return { body: (stamp as { body: unknown }).body };
}
}
return undefined;
}
Loading
Loading