Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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.
16 changes: 13 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,26 @@ 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, any COMPLETED non-auth HTTP
exchange whose direct (non-SSE) answer is not a valid modern reply 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. 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 @@

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, 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.

Check warning on line 104 in docs/protocol-versions.md

View check run for this annotation

Claude / Claude Code Review

Categorical completed-exchange rule isn't backed for direct JSON answers that produce no matching reply (empty batch [], schema-valid reply with wrong id) — these still burn the full probe timeout

The categorical completed-exchange rule this PR adds here ("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") isn't backed by the implementation for two direct `application/json` shapes: a 200 body of `[]` (zero messages produced, `_send` resolves, no stamp fires) and a schema-valid reply with a non-matching id (e.g. `id: 1` substituted for the probe's string id — the sibling of the
Comment thread
claude[bot] marked this conversation as resolved.
Outdated

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
5 changes: 3 additions & 2 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,10 @@ 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.
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.

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
53 changes: 53 additions & 0 deletions packages/client/src/client/invalidReplySeam.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* 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');

/**
* 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