From 8d5fee18a38f45b0c4eaad674e2af6820c299694 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 16:36:29 +0000 Subject: [PATCH 1/3] fix(client): fall back to initialize when the discover probe gets a completed non-modern answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under versionNegotiation mode 'auto', a server/discover probe answered by a completed, non-auth HTTP exchange that is not a valid modern reply now selects the legacy fallback instead of failing connect(): - 2xx replies whose JSON body fails strict JSON-RPC validation (e.g. the JSON-RPC 2.0 parse-error shape -32700 with id: null — anomalyco/opencode#39354, or error replies with extra/unknown members), stamped at the transport's parse boundary and classified by the probe classifier - 2xx application/json replies with an empty or unparseable body - 2xx replies in a non-MCP content type (HTML error pages, text/plain, missing content-type) - 202 Accepted answers to the probe: immediate legacy evidence instead of waiting out the full probe timeout - any 5xx, with or without a JSON-RPC error body (hosts caching era verdicts should date legacy verdicts; the SDK never persists one) Unchanged: 401/403 stay typed auth failures, network failures and the HTTP probe timeout still reject, and pin mode never falls back. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013EfMZraQUoWyeSBT2HaQ5v --- .changeset/rude-servers-fall-back.md | 13 + docs/migration/support-2026-07-28.md | 13 +- docs/protocol-versions.md | 4 +- .../client/src/client/invalidReplySeam.ts | 53 ++++ packages/client/src/client/probeClassifier.ts | 72 ++++-- packages/client/src/client/streamableHttp.ts | 47 +++- .../client/src/client/versionNegotiation.ts | 16 ++ .../test/client/probeClassifier.test.ts | 42 ++++ .../test/client/probeFixtureCorpus.test.ts | 60 +++-- .../test/client/versionNegotiation.test.ts | 236 ++++++++++++++++++ .../core-internal/src/errors/sdkErrors.ts | 5 +- 11 files changed, 505 insertions(+), 56 deletions(-) create mode 100644 .changeset/rude-servers-fall-back.md create mode 100644 packages/client/src/client/invalidReplySeam.ts diff --git a/.changeset/rude-servers-fall-back.md b/.changeset/rude-servers-fall-back.md new file mode 100644 index 0000000000..40b8520112 --- /dev/null +++ b/.changeset/rude-servers-fall-back.md @@ -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. diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index 13e869c0f2..c9b0a717ad 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -70,7 +70,15 @@ 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 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 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 @@ -78,8 +86,7 @@ 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)` — diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md index ba1338b0d2..6a05013a8f 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -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, however broken. Any completed non-auth HTTP exchange whose 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. 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. diff --git a/packages/client/src/client/invalidReplySeam.ts b/packages/client/src/client/invalidReplySeam.ts new file mode 100644 index 0000000000..1b24c723c2 --- /dev/null +++ b/packages/client/src/client/invalidReplySeam.ts @@ -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(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)[INVALID_REPLY]; + if (typeof stamp === 'object' && stamp !== null && 'body' in stamp) { + return { body: (stamp as { body: unknown }).body }; + } + } + return undefined; +} diff --git a/packages/client/src/client/probeClassifier.ts b/packages/client/src/client/probeClassifier.ts index e23d011a2a..3016262c2f 100644 --- a/packages/client/src/client/probeClassifier.ts +++ b/packages/client/src/client/probeClassifier.ts @@ -48,6 +48,15 @@ export type ProbeOutcome = | { kind: 'result'; result: unknown } /** Answered with a JSON-RPC error (any HTTP status, including 200-bodied errors and stdio in-band errors). */ | { kind: 'rpc-error'; code: number; message: string; data?: unknown } + /** + * The HTTP layer COMPLETED the probe exchange (2xx) without producing a + * valid JSON-RPC reply: a JSON body failing strict message validation + * (`body` carries the offending parsed value — e.g. an error reply with + * `id: null` or unknown members), or a bodiless failure (`body` absent) — + * an empty/unparseable body, a non-MCP content type such as an HTML error + * page, or a 202 accepted-without-reply. + */ + | { kind: 'invalid-reply'; body?: unknown } /** The HTTP layer rejected the probe POST (non-2xx); `body` is the raw response text and `statusText` the HTTP reason phrase, when available. */ | { kind: 'http-error'; status: number; body?: string; statusText?: string } | { kind: 'network-error'; error: unknown } @@ -133,6 +142,9 @@ export function classifyProbeOutcome(outcome: ProbeOutcome, context: ProbeClassi case 'rpc-error': { return classifyRpcError(outcome, context); } + case 'invalid-reply': { + return classifyInvalidReply(outcome, context); + } case 'http-error': { return classifyHttpError(outcome, context); } @@ -243,6 +255,25 @@ function classifyRpcError(outcome: { code: number; message: string; data?: unkno return { kind: 'legacy' }; } +function classifyInvalidReply(outcome: { body?: unknown }, context: ProbeClassifierContext): ProbeVerdict { + // The server COMPLETED the probe exchange (2xx at the HTTP layer), but + // what came back is not a valid modern reply: a JSON body the strict + // message schema rejects (the JSON-RPC 2.0 parse-error reply a deployed + // 2025 server sends for the unknown probe — `-32700` with `id: null` — + // extra members, missing fields), an empty or unparseable body, a non-MCP + // content type (a proxy's HTML error page), or a 202 accepted-without- + // reply. A completed non-auth exchange is era evidence, and a non-modern + // answer is a legacy signal — the same conservative rule as the + // unparseable-4xx row; auth statuses, network failures, and true silence + // keep their typed errors. A leniently readable JSON-RPC `error` member + // classifies like an in-band error first, keeping the -32022 rows. + const rpcError = parseJsonRpcErrorMember(outcome.body); + if (rpcError !== undefined) { + return classifyRpcError(rpcError, context); + } + return { kind: 'legacy' }; +} + function classifyHttpError(outcome: { status: number; body?: string; statusText?: string }, context: ProbeClassifierContext): ProbeVerdict { // Auth statuses are never era evidence, and a 401/403 body is the auth // layer's, not the server/discover handler's — this row ranks above the @@ -268,35 +299,21 @@ function classifyHttpError(outcome: { status: number; body?: string; statusText? ) }; } - if (outcome.status >= 500) { - // A server failure is not era evidence, whatever the body says — this - // row too ranks above the JSON-RPC body parse (a 5xx body is the - // infrastructure's: a mid-deploy gateway's JSON error page, a crashed - // handler's -32603, neither a server/discover verdict). The spec keys - // the HTTP legacy signal to client-error rejections (a 2025 server - // answers the unknown probe 4xx), never to 5xx. - return { - kind: 'error', - error: new SdkHttpError( - SdkErrorCode.EraNegotiationFailed, - `Version negotiation failed: the server answered the probe with HTTP ${outcome.status}`, - { - status: outcome.status, - statusText: outcome.statusText, - text: outcome.body - } - ) - }; - } - // HTTP-rejected probes carry their JSON-RPC error in the response body — classify it like an in-band error. + // Every other completed HTTP rejection — the remaining 4xx AND the 5xx + // range — is era evidence: a deployed 2025 server answers the unknown + // probe with a client error, and poorly behaved deployments answer it + // with a server error (frameworks that map JSON-RPC errors to 500, + // gateways that 502 unknown routes). Neither is a modern server/discover + // answer. A JSON-RPC error body classifies like an in-band error (keeping + // the -32022 rows); anything else is the conservative legacy fallback. + // Note for hosts that cache era verdicts (the gateway guide's + // `connect({ prior })` flow): a 5xx can also be a modern server having a + // transient failure, so date cached legacy verdicts and let them expire — + // the SDK itself never persists a verdict. const rpcError = parseJsonRpcErrorBody(outcome.body); if (rpcError !== undefined) { return classifyRpcError(rpcError, context); } - // Unparseable or unrecognized 4xx rejection: conservative legacy fallback. - // With the auth and 5xx rows above, this row's legacy set now equals the - // spec-licensed set exactly — the 4xx a deployed 2025 server answers a - // request it does not recognize. return { kind: 'legacy' }; } @@ -349,6 +366,11 @@ function parseJsonRpcErrorBody(body: string | undefined): { code: number; messag } catch { return undefined; } + return parseJsonRpcErrorMember(parsed); +} + +/** Leniently read a JSON-RPC `error` member (numeric `code` required) off an already-parsed JSON value. */ +function parseJsonRpcErrorMember(parsed: unknown): { code: number; message: string; data?: unknown } | undefined { if (typeof parsed !== 'object' || parsed === null) return undefined; const error = (parsed as { error?: unknown }).error; if (typeof error !== 'object' || error === null) return undefined; diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index ace0663158..06750c7203 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -35,6 +35,7 @@ import { import type { IssuerMismatchError } from './authErrors'; import { InsufficientScopeError } from './authErrors'; import { markAuthSeamEscape } from './authSeam'; +import { markInvalidReplyEscape } from './invalidReplySeam'; /** Default cap on step-up re-authorization retries within a single send/stream-open. */ const DEFAULT_MAX_STEP_UP_RETRIES = 1; @@ -303,6 +304,23 @@ function anySignal(a: AbortSignal, b: AbortSignal): AbortSignal { return controller.signal; } +/** + * Parse one 2xx JSON response-body value as a JSON-RPC message. A body that is + * valid JSON but fails the strict message schema still throws the validation + * error — stamped with the offending value at this boundary, so the + * version-negotiation probe can classify the invalid reply (deployed servers + * answer the unknown `server/discover` probe with off-spec error replies such + * as `{"error":{...},"id":null}`) instead of treating it as a network failure. + * Identity-preserving: every other caller keeps seeing the raw validation error. + */ +function parseJsonResponseMessage(value: unknown): JSONRPCMessage { + try { + return JSONRPCMessageSchema.parse(value); + } catch (error) { + throw markInvalidReplyEscape(error, value); + } +} + /** * Client transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification. * It will connect to a server using HTTP `POST` for sending messages and HTTP `GET` with Server-Sent Events @@ -1108,6 +1126,21 @@ export class StreamableHTTPClientTransport implements Transport { // If the response is 202 Accepted, there's no body to process if (response.status === 202) { await response.text?.().catch(() => {}); + // A 202 to the version-negotiation probe REQUEST is a completed + // exchange that will never produce a reply (the spec reserves + // 202 for notifications and responses): surface it immediately, + // stamped for the probe's classifier, instead of letting the + // probe wait out its full timeout. Scoped to the probe request + // — every other flow through this branch is unchanged. + if (!Array.isArray(message) && isJSONRPCRequest(message) && message.method === 'server/discover') { + throw markInvalidReplyEscape( + new SdkError( + SdkErrorCode.EraNegotiationFailed, + 'The server accepted the server/discover probe with HTTP 202 and will not reply' + ), + undefined + ); + } // if the accepted notification is initialized, we start the SSE stream // if it's supported by the server if (isInitializedNotification(message)) { @@ -1142,10 +1175,18 @@ export class StreamableHTTPClientTransport implements Transport { ); } else if (responseMediaType === 'application/json') { // For non-streaming servers, we might get direct JSON responses - const data = await response.json(); + let data: unknown; + try { + data = await response.json(); + } catch (error) { + // A 2xx `application/json` answer whose body is empty or + // not JSON at all: a completed exchange with no reply in + // it — stamped (bodiless) for the probe's classifier. + throw markInvalidReplyEscape(error, undefined); + } const responseMessages = Array.isArray(data) - ? data.map(msg => JSONRPCMessageSchema.parse(msg)) - : [JSONRPCMessageSchema.parse(data)]; + ? data.map(msg => parseJsonResponseMessage(msg)) + : [parseJsonResponseMessage(data)]; for (const msg of responseMessages) { this.onmessage?.(msg); diff --git a/packages/client/src/client/versionNegotiation.ts b/packages/client/src/client/versionNegotiation.ts index 9b89d962ba..936444c862 100644 --- a/packages/client/src/client/versionNegotiation.ts +++ b/packages/client/src/client/versionNegotiation.ts @@ -27,6 +27,7 @@ import { import { UnauthorizedError } from './auth'; import { isAuthSeamEscape } from './authSeam'; +import { readInvalidReplyEscape } from './invalidReplySeam'; import type { ProbeEnvironment, ProbeOutcome, ProbeTransportKind, ProbeVerdict } from './probeClassifier'; import { classifyProbeOutcome } from './probeClassifier'; @@ -409,6 +410,21 @@ function normalizeReply(reply: RawProbeReply, timeoutMs: number): ProbeOutcome { statusText: error.data.statusText }; } + const invalidReply = readInvalidReplyEscape(error); + if (invalidReply !== undefined) { + // The server COMPLETED the probe exchange (2xx) without a valid + // JSON-RPC reply — provenance stamped at the transport's parse + // boundary (an off-spec error body, empty/unparseable JSON, or a + // 202 accepted-without-reply). Not a network condition: the + // classifier owns the verdict. + return { kind: 'invalid-reply', body: invalidReply.body }; + } + if (error instanceof SdkError && error.code === SdkErrorCode.ClientHttpUnexpectedContent) { + // A 2xx answer in a non-MCP content type (a proxy's HTML error + // page, text/plain, a missing content-type): the same completed- + // exchange evidence, with no JSON-RPC body to read. + return { kind: 'invalid-reply' }; + } return { kind: 'network-error', error }; } case 'closed': { diff --git a/packages/client/test/client/probeClassifier.test.ts b/packages/client/test/client/probeClassifier.test.ts index 3a65f240b7..b4e28f9483 100644 --- a/packages/client/test/client/probeClassifier.test.ts +++ b/packages/client/test/client/probeClassifier.test.ts @@ -173,6 +173,48 @@ describe('row: -32601 → legacy (never modern evidence on the probe, including }); }); +describe('row: completed 2xx exchange without a valid JSON-RPC reply (invalid-reply) → legacy', () => { + /** The exact wire shape from anomalyco/opencode#39354: HTTP 200 + the JSON-RPC 2.0 parse-error reply (`id: null`, -32700). */ + const oc39354Body = JSON.parse('{"error":{"code":-32700,"message":"Parse Error"},"id":null,"jsonrpc":"2.0"}') as unknown; + + test('the opencode#39354 shape: 200 + -32700 + id:null → legacy', () => { + expect(classify({ kind: 'invalid-reply', body: oc39354Body })).toEqual({ kind: 'legacy' }); + }); + + test('an error reply with extra/unknown members → legacy', () => { + const body = { + jsonrpc: '2.0', + id: 'server-discover-probe-1', + error: { code: -32_601, message: 'Method not found' }, + detail: 'extra member the strict schema rejects' + }; + expect(classify({ kind: 'invalid-reply', body })).toEqual({ kind: 'legacy' }); + }); + + test('an unreadable error member (missing/non-numeric code) is still a legacy signal — the unparseable-4xx rule', () => { + expect(classify({ kind: 'invalid-reply', body: { error: { message: 'no code' } } })).toEqual({ kind: 'legacy' }); + expect(classify({ kind: 'invalid-reply', body: { error: 'oops' } })).toEqual({ kind: 'legacy' }); + }); + + test('a readable -32022 error member keeps the corrective row (classified like an in-band error)', () => { + const body = { + jsonrpc: '2.0', + id: null, + error: { code: -32_022, message: 'Unsupported protocol version', data: { supported: [MODERN] } } + }; + expect(classify({ kind: 'invalid-reply', body })).toMatchObject({ kind: 'corrective', version: MODERN }); + }); + + test('a schema-invalid reply WITHOUT an error member (malformed result) is the same completed-exchange legacy signal', () => { + expect(classify({ kind: 'invalid-reply', body: { jsonrpc: '2.0', id: null, result: 'garbage' } })).toEqual({ kind: 'legacy' }); + }); + + test('bodiless invalid replies (empty/unparseable JSON, HTML/wrong content type, 202 accepted-without-reply) → legacy', () => { + expect(classify({ kind: 'invalid-reply' })).toEqual({ kind: 'legacy' }); + expect(classify({ kind: 'invalid-reply', body: undefined })).toEqual({ kind: 'legacy' }); + }); +}); + describe('row: 400 + -32000 "Unsupported protocol version" literal (deployed TS-SDK fleet, stateless) → legacy', () => { test('the byte-real literal body', () => { // Fixture mirrors server/streamableHttp.ts validateProtocolVersion — the diff --git a/packages/client/test/client/probeFixtureCorpus.test.ts b/packages/client/test/client/probeFixtureCorpus.test.ts index 8dd2eefb4f..b191ee434c 100644 --- a/packages/client/test/client/probeFixtureCorpus.test.ts +++ b/packages/client/test/client/probeFixtureCorpus.test.ts @@ -184,32 +184,35 @@ const CORPUS: CorpusRow[] = [ outcome: { kind: 'http-error', status: 401, body: DEPLOYED_SESSION_REQUIRED_BODY }, expected: 'error' }, - // --- Server failures are never era evidence: the spec keys the HTTP - // legacy signal to a 4xx rejection, so a 5xx (mid-deploy proxy, crashed - // backend) rejects typed instead of demoting a modern server to legacy. + // --- Server errors are completed exchanges and therefore era evidence: + // deployed frameworks map JSON-RPC errors to 500 and gateways 502 unknown + // routes, so a 5xx answer to the probe selects the legacy fallback (a + // genuinely failing server just fails the fallback initialize with its + // own error). Hosts caching era verdicts should date legacy verdicts — + // a 5xx can be a modern server's transient failure. { - name: '5xx: HTTP 503 with an HTML error page (mid-deploy modern server) → typed error, never legacy', + name: '5xx: HTTP 503 with an HTML error page → legacy fallback', outcome: { kind: 'http-error', status: 503, body: 'Service Unavailable' }, - expected: 'error' + expected: 'legacy' }, { - name: '5xx: bare HTTP 500 (no body) → typed error, never legacy', + name: '5xx: bare HTTP 500 (no body) → legacy fallback', outcome: { kind: 'http-error', status: 500 }, - expected: 'error' + expected: 'legacy' }, { - name: '5xx: HTTP 502 with a JSON (but not JSON-RPC) gateway body → typed error, never legacy', + name: '5xx: HTTP 502 with a JSON (but not JSON-RPC) gateway body → legacy fallback', outcome: { kind: 'http-error', status: 502, body: '{"message":"upstream connect error"}' }, - expected: 'error' + expected: 'legacy' }, { - name: '5xx: HTTP 500 whose body parses as a JSON-RPC error (-32603 from a crashed handler) is still a server failure — 5xx ranks above the body parse', + name: '5xx: HTTP 500 whose body parses as a JSON-RPC error (-32603, a framework mapping JSON-RPC errors to 500) → legacy fallback', outcome: { kind: 'http-error', status: 500, body: JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32_603, message: 'Internal error' } }) }, - expected: 'error' + expected: 'legacy' }, // --- Q12 transport-aware timeout rows (stdio falls back, HTTP stays a typed error). { @@ -229,6 +232,24 @@ const CORPUS: CorpusRow[] = [ outcome: { kind: 'rpc-error', code: -32_601, message: 'Method not found' }, expected: 'legacy' }, + // --- Wire-real shape (anomalyco/opencode#39354): a 200-accepted reply that + // fails strict JSON-RPC validation. A completed non-auth exchange is era + // evidence — a non-modern answer selects the legacy fallback (the same + // rule as the unparseable-4xx row; note the former 200-vs-404 asymmetry: + // this exact body already fell back at 404 via the lenient body parse). + { + name: 'wire-real (oc#39354): 200 + JSON-RPC 2.0 parse-error reply (-32700, id: null) failing strict validation → legacy fallback', + outcome: { + kind: 'invalid-reply', + body: { jsonrpc: '2.0', id: null, error: { code: -32_700, message: 'Parse Error' } } + }, + expected: 'legacy' + }, + { + name: 'invalid-reply: bodiless completed exchange (200 empty/unparseable JSON, HTML content type, 202 accepted-without-reply) → legacy fallback', + outcome: { kind: 'invalid-reply' }, + expected: 'legacy' + }, // --- Wire-real shape C: the #3002 final-revision DiscoverResult (go v1.7.0-pre.3). // Regression: before the #3002 alignment the wire schema required body // `serverInfo`, so this conforming response failed parse and misclassified @@ -278,19 +299,16 @@ describe('T9/T11 merged probe fixture corpus (probe classifier)', () => { } }); - it('the 5xx typed failure is EraNegotiationFailed (a genuine negotiation failure) carrying the status', () => { + it('a 5xx probe answer for a modern-only client (no fallback available) still classifies legacy — the caller reports the typed negotiation error', () => { const verdict = classifyProbeOutcome( { kind: 'http-error', status: 503, statusText: 'Service Unavailable', body: 'down' }, - baseContext + { ...baseContext, fallbackAvailable: false } ); - expect(verdict.kind).toBe('error'); - if (verdict.kind === 'error') { - expect(verdict.error).toBeInstanceOf(SdkHttpError); - const error = verdict.error as SdkHttpError; - expect(error.code).toBe(SdkErrorCode.EraNegotiationFailed); - expect(error.status).toBe(503); - expect(error.message).toContain('503'); - } + // Non-modern evidence stays a legacy classification; negotiateEra + // converts it to SdkError(EraNegotiationFailed) when no pre-2026 + // version is available to fall back to (see the classifier context + // contract on fallbackAvailable). + expect(verdict.kind).toBe('legacy'); }); it('a DiscoverResult with a mutual version is the only result shape that yields a modern verdict', () => { diff --git a/packages/client/test/client/versionNegotiation.test.ts b/packages/client/test/client/versionNegotiation.test.ts index 60c9a2aaba..cf4bf59ded 100644 --- a/packages/client/test/client/versionNegotiation.test.ts +++ b/packages/client/test/client/versionNegotiation.test.ts @@ -24,7 +24,9 @@ import { UnauthorizedError } from '../../src/client/auth'; import { InsufficientScopeError } from '../../src/client/authErrors'; import { markAuthSeamEscape } from '../../src/client/authSeam'; import { Client } from '../../src/client/client'; +import { markInvalidReplyEscape } from '../../src/client/invalidReplySeam'; import type { StreamableHTTPClientTransportOptions } from '../../src/client/streamableHttp'; +import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp'; import type { StdioServerParameters } from '../../src/client/stdio'; import { resolveVersionNegotiation } from '../../src/client/versionNegotiation'; @@ -1390,3 +1392,237 @@ describe('probe window preserves pre-set transport handlers', () => { await client.close(); }); }); + +/* ------------------------------------------------------------------------- * + * Invalid probe replies (completed 2xx exchange, no valid JSON-RPC reply): + * stamped at the transport parse boundary, classified legacy — the probe + * falls back to initialize instead of failing connect(). + * ------------------------------------------------------------------------- */ + +/** The exact reply the anomalyco/opencode#39354 vendor server sends the probe (HTTP 200). */ +const OC39354_BODY = '{"error":{"code":-32700,"message":"Parse Error"},"id":null,"jsonrpc":"2.0"}'; + +describe('probe invalid-reply classification (stamped escapes from the transport parse boundary)', () => { + /** Rejects the probe send with `probeError` (as the real transport does for an unparseable 2xx reply), then serves legacy initialize. */ + class InvalidReplyTransport extends ScriptedTransport { + constructor(private readonly probeError: Error) { + super(legacyServerScript); + } + + override async send(message: JSONRPCMessage): Promise { + if (isJSONRPCRequest(message) && message.method === 'server/discover') { + throw this.probeError; + } + await super.send(message); + } + } + + test('a stamped invalid-reply escape (oc#39354 body) falls back to initialize on the same transport', async () => { + const stamped = markInvalidReplyEscape(new Error('validation failed'), JSON.parse(OC39354_BODY)); + const transport = new InvalidReplyTransport(stamped); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + await client.connect(transport); + + expect(requests(transport.sent).some(r => r.method === 'initialize')).toBe(true); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + await client.close(); + }); + + test('a stamped bodiless escape (empty/unparseable JSON, 202 accepted-without-reply) falls back the same way', async () => { + const stamped = markInvalidReplyEscape(new Error('Unexpected end of JSON input'), undefined); + const transport = new InvalidReplyTransport(stamped); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + await client.connect(transport); + + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + await client.close(); + }); + + test('pin mode: an invalid reply still fails loudly — no fallback', async () => { + const stamped = markInvalidReplyEscape(new Error('validation failed'), JSON.parse(OC39354_BODY)); + const transport = new InvalidReplyTransport(stamped); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + ); + expect(requests(transport.sent).some(r => r.method === 'initialize')).toBe(false); + }); + + test('modern-only client: typed error instead of a fallback the client cannot run', async () => { + const stamped = markInvalidReplyEscape(new Error('validation failed'), JSON.parse(OC39354_BODY)); + const transport = new InvalidReplyTransport(stamped); + const client = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: [MODERN] } + ); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + ); + expect(requests(transport.sent).some(r => r.method === 'initialize')).toBe(false); + }); +}); + +/* ------------------------------------------------------------------------- * + * Wire-real regression (anomalyco/opencode#39354 and its cousins) through the + * REAL StreamableHTTPClientTransport with a scripted fetch: every completed + * non-auth HTTP exchange whose answer is not a valid modern reply — off-spec + * 2xx bodies, wrong content types, 202-without-reply, unrecognized 4xx, and + * any 5xx — falls back to initialize; auth statuses, network failures, and + * probe timeouts keep their typed errors. + * ------------------------------------------------------------------------- */ + +describe('wire-real invalid probe replies over StreamableHTTPClientTransport (oc#39354)', () => { + /** A scripted 2025 HTTP server: `probeResponse` answers the probe POST; initialize succeeds; GET has no SSE stream. */ + function scriptedLegacyFetch(probeResponse: () => Response): { fetch: typeof fetch; methods: string[] } { + const methods: string[] = []; + const fetchImpl = (async (_input: string | URL | Request, init?: RequestInit): Promise => { + if ((init?.method ?? 'GET') !== 'POST') { + return new Response('Method Not Allowed', { status: 405 }); + } + const parsed = JSON.parse(String(init?.body)) as { + id?: string | number; + method?: string; + params?: { protocolVersion?: string }; + }; + if (parsed.method !== undefined) methods.push(parsed.method); + if (parsed.method === 'server/discover') { + return probeResponse(); + } + if (parsed.method === 'initialize') { + return Response.json({ + jsonrpc: '2.0', + id: parsed.id, + result: { + protocolVersion: parsed.params?.protocolVersion ?? '2025-11-25', + capabilities: {}, + serverInfo: { name: 'wire-real-legacy-server', version: '1.0.0' } + } + }); + } + return new Response(null, { status: 202 }); + }) as typeof fetch; + return { fetch: fetchImpl, methods }; + } + + async function connectAuto(probeResponse: () => Response, probeTimeoutMs = 60_000) { + const scripted = scriptedLegacyFetch(probeResponse); + const transport = new StreamableHTTPClientTransport(new URL('https://rude.example.com/mcp'), { fetch: scripted.fetch }); + const client = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto', probe: { timeoutMs: probeTimeoutMs } } } + ); + await client.connect(transport); + return { client, methods: scripted.methods }; + } + + test('the oc#39354 reply (200 + -32700 + id:null) falls back — connect resolves on the legacy era', async () => { + const { client, methods } = await connectAuto( + () => new Response(OC39354_BODY, { status: 200, headers: { 'content-type': 'application/json' } }) + ); + expect(methods).toContain('initialize'); + expect(client.getProtocolEra()).toBe('legacy'); + expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25'); + await client.close(); + }); + + test('200 + empty application/json body falls back', async () => { + const { client, methods } = await connectAuto( + () => new Response('', { status: 200, headers: { 'content-type': 'application/json' } }) + ); + expect(methods).toContain('initialize'); + expect(client.getProtocolEra()).toBe('legacy'); + await client.close(); + }); + + test('200 + invalid JSON falls back', async () => { + const { client, methods } = await connectAuto( + () => new Response(' { + const { client, methods } = await connectAuto( + () => new Response('Bad Gateway', { status: 200, headers: { 'content-type': 'text/html' } }) + ); + expect(methods).toContain('initialize'); + expect(client.getProtocolEra()).toBe('legacy'); + await client.close(); + }); + + test('200 + wrong content type (text/plain carrying a valid -32601 body) falls back', async () => { + const body = JSON.stringify({ + jsonrpc: '2.0', + id: 'server-discover-probe-1', + error: { code: -32_601, message: 'Method not found' } + }); + const { client, methods } = await connectAuto(() => new Response(body, { status: 200, headers: { 'content-type': 'text/plain' } })); + expect(methods).toContain('initialize'); + expect(client.getProtocolEra()).toBe('legacy'); + await client.close(); + }); + + test('200 with NO content type falls back', async () => { + // A TypedArray body sets no default content-type header. + const { client, methods } = await connectAuto(() => new Response(new TextEncoder().encode('{}'), { status: 200 })); + expect(methods).toContain('initialize'); + expect(client.getProtocolEra()).toBe('legacy'); + await client.close(); + }); + + test('202 accepted-without-reply is immediate legacy evidence — no probe-timeout wait', async () => { + const startedAt = Date.now(); + // The 60s probe timeout would fail the suite if the 202 were treated as + // silence; the fallback must run immediately off the completed exchange. + const { client, methods } = await connectAuto(() => new Response(null, { status: 202 }), 60_000); + expect(Date.now() - startedAt).toBeLessThan(2_000); + expect(methods).toContain('initialize'); + expect(client.getProtocolEra()).toBe('legacy'); + await client.close(); + }); + + test('a bare 500 (empty-ish body) on the probe falls back — a completed server-error exchange is era evidence', async () => { + const { client, methods } = await connectAuto(() => new Response('Internal Server Error', { status: 500 })); + expect(methods).toContain('initialize'); + expect(client.getProtocolEra()).toBe('legacy'); + await client.close(); + }); + + test('a 500 carrying a well-formed JSON-RPC error body (framework mapping JSON-RPC errors to 500) falls back', async () => { + const body = JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32_601, message: 'Method not found' } }); + const { client, methods } = await connectAuto( + () => new Response(body, { status: 500, headers: { 'content-type': 'application/json' } }) + ); + expect(methods).toContain('initialize'); + expect(client.getProtocolEra()).toBe('legacy'); + await client.close(); + }); + + test('a 502 HTML gateway error page on the probe falls back', async () => { + const { client, methods } = await connectAuto( + () => new Response('Bad Gateway', { status: 502, headers: { 'content-type': 'text/html' } }) + ); + expect(methods).toContain('initialize'); + expect(client.getProtocolEra()).toBe('legacy'); + await client.close(); + }); + + test('control: a network failure on the probe still rejects typed — never a fallback', async () => { + const netError = new TypeError('fetch failed'); + const fetchMock = (async (): Promise => { + throw netError; + }) as typeof fetch; + const transport = new StreamableHTTPClientTransport(new URL('https://rude.example.com/mcp'), { fetch: fetchMock }); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + ); + }); +}); diff --git a/packages/core-internal/src/errors/sdkErrors.ts b/packages/core-internal/src/errors/sdkErrors.ts index 0bc8f9a1ad..48ed6d6f52 100644 --- a/packages/core-internal/src/errors/sdkErrors.ts +++ b/packages/core-internal/src/errors/sdkErrors.ts @@ -65,9 +65,8 @@ export enum SdkErrorCode { /** * Protocol-era negotiation at connect time failed without producing either a * usable modern (2026-07-28+) era or a definitive legacy fallback signal — - * e.g. the negotiation mode forbids falling back (`pin`), the probe hit a - * network failure, or the server answered the probe with a 5xx (a typed - * connect error, never an era verdict). + * e.g. the negotiation mode forbids falling back (`pin`), or the probe hit + * a network failure (a typed connect error, never an era verdict). * * Negotiation-phase only: this code is never used once an era is * established. Auth walls never carry it: a 401/403 rejecting the probe From b226ba94afdcd13846362e762dbab427f77117f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 17:37:50 +0000 Subject: [PATCH 2/3] fix(client): keep mid-body network failures out of the invalid-reply fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review on the completed-exchange fallback: - Read the 2xx application/json body via text() before parsing: a body-read rejection is a network failure mid-transfer (undici TypeError('terminated') after a connection reset behind 2xx headers) and now propagates unstamped to the typed EraNegotiationFailed rejection instead of classifying as legacy evidence. Only a JSON.parse failure of the fully received text is stamped for the probe classifier, so the empty-body and invalid-JSON fallbacks are unchanged. - Wire-real regression test: 200 + application/json headers followed by a mid-body socket destroy rejects connect() typed and never sends initialize. - Docs: qualify the completed-exchange rule to answers delivered as a completed HTTP body — a probe answered 200 + text/event-stream is classified by the reply the stream delivers, and an SSE stream carrying only a schema-invalid reply (or none) still runs out the probe timeout, as before. - docs/troubleshooting.md: replace the stale "HTTP 5xx -> no legacy fallback" bullet — a 5xx probe answer now selects the legacy initialize fallback, with the date-your-cached-legacy-verdicts caveat. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013EfMZraQUoWyeSBT2HaQ5v --- docs/migration/support-2026-07-28.md | 19 ++++++----- docs/protocol-versions.md | 2 +- docs/troubleshooting.md | 5 +-- packages/client/src/client/streamableHttp.ts | 19 ++++++++--- .../client/bodyDerivedProbeHeaders.test.ts | 2 +- .../client/test/client/streamableHttp.test.ts | 2 +- .../test/client/versionNegotiation.test.ts | 32 +++++++++++++++++++ 7 files changed, 63 insertions(+), 18 deletions(-) diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index c9b0a717ad..279d835557 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -71,14 +71,17 @@ 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. In particular, any COMPLETED non-auth HTTP -exchange whose 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 network outage rejects with a typed connect +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 diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md index 6a05013a8f..5ba963d50c 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -101,7 +101,7 @@ 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. -A server that completes the exchange is classified by what it answers, however broken. Any completed non-auth HTTP exchange whose 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. 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. +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. 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. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d9e6e7824d..f0ffa53f2d 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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): diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 06750c7203..2709833456 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -1174,14 +1174,23 @@ export class StreamableHTTPClientTransport implements Transport { false ); } else if (responseMediaType === 'application/json') { - // For non-streaming servers, we might get direct JSON responses + // For non-streaming servers, we might get direct JSON responses. + // + // Read the body as text first: a `text()` rejection is a + // network failure mid-body-read (undici rejects with + // `TypeError('terminated')` when the connection resets + // after the 2xx headers arrived) — the exchange did NOT + // complete, so it propagates unstamped and stays a typed + // network error. Only the fully received text proves a + // completed exchange; a body that then fails `JSON.parse` + // (empty, or not JSON at all) is a completed exchange with + // no reply in it — that failure alone is stamped + // (bodiless) for the probe's classifier. + const bodyText = await response.text(); let data: unknown; try { - data = await response.json(); + data = JSON.parse(bodyText); } catch (error) { - // A 2xx `application/json` answer whose body is empty or - // not JSON at all: a completed exchange with no reply in - // it — stamped (bodiless) for the probe's classifier. throw markInvalidReplyEscape(error, undefined); } const responseMessages = Array.isArray(data) diff --git a/packages/client/test/client/bodyDerivedProbeHeaders.test.ts b/packages/client/test/client/bodyDerivedProbeHeaders.test.ts index 26cfebf656..39174c0278 100644 --- a/packages/client/test/client/bodyDerivedProbeHeaders.test.ts +++ b/packages/client/test/client/bodyDerivedProbeHeaders.test.ts @@ -20,7 +20,7 @@ describe('body-derived probe headers', () => { ok: true, status: 200, headers: new Headers({ 'content-type': 'application/json' }), - json: () => Promise.resolve(body) + text: () => Promise.resolve(JSON.stringify(body)) }); beforeEach(async () => { diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index a36bbc0ad3..b05586b1c0 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -387,7 +387,7 @@ describe('StreamableHTTPClientTransport', () => { ok: true, status: 200, headers: new Headers({ 'content-type': 'application/json' }), - json: () => Promise.resolve(responseMessage) + text: () => Promise.resolve(JSON.stringify(responseMessage)) }); const messageSpy = vi.fn(); diff --git a/test/integration/test/client/versionNegotiation.test.ts b/test/integration/test/client/versionNegotiation.test.ts index c6e70446cd..30b96f7677 100644 --- a/test/integration/test/client/versionNegotiation.test.ts +++ b/test/integration/test/client/versionNegotiation.test.ts @@ -228,6 +228,38 @@ describe('typed connect errors (Q12) over real sockets', () => { await new Promise(resolve => hang.close(() => resolve())); await new Promise(resolve => setTimeout(resolve, 50)); }, 15_000); + + it('mid-body socket destroy after 2xx application/json headers: typed connect error, never a legacy verdict', async () => { + // The server answers the probe 200 + application/json, starts the + // body, then the connection dies before the body completes (undici + // rejects the body read with TypeError('terminated')). The exchange + // did NOT complete — a truncated body carries zero era evidence — so + // this must stay a typed network rejection, not an invalid-reply + // fallback to initialize. + const reset = createServer((req, res) => { + req.resume(); + req.on('end', () => { + res.writeHead(200, { 'content-type': 'application/json', 'content-length': '512' }); + res.write('{"jsonrpc":"2.0","id":"server-discover-', () => { + setTimeout(() => res.socket?.destroy(), 10); + }); + }); + }); + const url = await listenOnRandomPort(reset); + + const { calls, fetchFn } = recordingFetch(); + const client = new Client({ name: 'neg-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + const transport = new StreamableHTTPClientTransport(url, { fetch: fetchFn }); + + await expect(client.connect(transport)).rejects.toSatisfy( + error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + ); + + // Probe traffic only — the truncated body never selected the legacy fallback. + expect(calls.some(c => (c.body ?? '').includes('"initialize"'))).toBe(false); + + await new Promise(resolve => reset.close(() => resolve())); + }, 15_000); }); describe('auth-protected server (HTTP 401/403): typed auth failure, never a legacy verdict (#2561)', () => { From 573da02dd3a34eaef5459fa8a82dcecb39873cc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 18:29:21 +0000 Subject: [PATCH 3/3] fix(client): scope the probe 202 row to probe ids and name the cause on pin/modern-only rejections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_013EfMZraQUoWyeSBT2HaQ5v --- .changeset/rude-servers-fall-back.md | 2 +- docs/migration/support-2026-07-28.md | 10 +- docs/protocol-versions.md | 2 +- docs/troubleshooting.md | 6 +- .../client/src/client/invalidReplySeam.ts | 10 ++ packages/client/src/client/probeClassifier.ts | 6 +- packages/client/src/client/streamableHttp.ts | 18 ++- .../client/src/client/versionNegotiation.ts | 50 ++++++-- .../test/client/versionNegotiation.test.ts | 117 +++++++++++++++++- .../core-internal/src/errors/sdkErrors.ts | 6 +- 10 files changed, 193 insertions(+), 34 deletions(-) diff --git a/.changeset/rude-servers-fall-back.md b/.changeset/rude-servers-fall-back.md index 40b8520112..622a47fa4f 100644 --- a/.changeset/rude-servers-fall-back.md +++ b/.changeset/rude-servers-fall-back.md @@ -10,4 +10,4 @@ - 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. +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. diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index 279d835557..d4ab9b72a1 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -70,8 +70,10 @@ 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. In particular, any COMPLETED non-auth HTTP -exchange whose direct (non-SSE) answer is not a valid modern reply is legacy evidence +`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 @@ -81,7 +83,9 @@ should date legacy verdicts — a `5xx` can be a modern server's transient failu 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 +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 diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md index 5ba963d50c..ce9b83928e 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -101,7 +101,7 @@ 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. -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. +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. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f0ffa53f2d..8e34c947e7 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -69,14 +69,14 @@ 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. -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. +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. diff --git a/packages/client/src/client/invalidReplySeam.ts b/packages/client/src/client/invalidReplySeam.ts index 1b24c723c2..8b93325135 100644 --- a/packages/client/src/client/invalidReplySeam.ts +++ b/packages/client/src/client/invalidReplySeam.ts @@ -19,6 +19,16 @@ */ 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 diff --git a/packages/client/src/client/probeClassifier.ts b/packages/client/src/client/probeClassifier.ts index 3016262c2f..44993cc130 100644 --- a/packages/client/src/client/probeClassifier.ts +++ b/packages/client/src/client/probeClassifier.ts @@ -54,9 +54,11 @@ export type ProbeOutcome = * (`body` carries the offending parsed value — e.g. an error reply with * `id: null` or unknown members), or a bodiless failure (`body` absent) — * an empty/unparseable body, a non-MCP content type such as an HTML error - * page, or a 202 accepted-without-reply. + * page, or a 202 accepted-without-reply. `cause` carries the original + * error the transport stamped at its parse boundary, so the modes that + * cannot fall back (pin, modern-only) can surface it as diagnostics. */ - | { kind: 'invalid-reply'; body?: unknown } + | { kind: 'invalid-reply'; body?: unknown; cause?: unknown } /** The HTTP layer rejected the probe POST (non-2xx); `body` is the raw response text and `statusText` the HTTP reason phrase, when available. */ | { kind: 'http-error'; status: number; body?: string; statusText?: string } | { kind: 'network-error'; error: unknown } diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 2709833456..0e1ecd4ec4 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -35,7 +35,7 @@ import { import type { IssuerMismatchError } from './authErrors'; import { InsufficientScopeError } from './authErrors'; import { markAuthSeamEscape } from './authSeam'; -import { markInvalidReplyEscape } from './invalidReplySeam'; +import { markInvalidReplyEscape, SERVER_DISCOVER_PROBE_ID_PREFIX } from './invalidReplySeam'; /** Default cap on step-up re-authorization retries within a single send/stream-open. */ const DEFAULT_MAX_STEP_UP_RETRIES = 1; @@ -1130,9 +1130,19 @@ export class StreamableHTTPClientTransport implements Transport { // exchange that will never produce a reply (the spec reserves // 202 for notifications and responses): surface it immediately, // stamped for the probe's classifier, instead of letting the - // probe wait out its full timeout. Scoped to the probe request - // — every other flow through this branch is unchanged. - if (!Array.isArray(message) && isJSONRPCRequest(message) && message.method === 'server/discover') { + // probe wait out its full timeout. Scoped to the connect-time + // probe by its reserved string id prefix (the probe never uses + // Protocol's numeric ids), so the public post-connect + // `Client.discover()` request can never match — a 202 answering + // it stays pending until the ordinary request timeout, and + // every other flow through this branch is unchanged. + if ( + !Array.isArray(message) && + isJSONRPCRequest(message) && + message.method === 'server/discover' && + typeof message.id === 'string' && + message.id.startsWith(SERVER_DISCOVER_PROBE_ID_PREFIX) + ) { throw markInvalidReplyEscape( new SdkError( SdkErrorCode.EraNegotiationFailed, diff --git a/packages/client/src/client/versionNegotiation.ts b/packages/client/src/client/versionNegotiation.ts index 936444c862..1d79b3f719 100644 --- a/packages/client/src/client/versionNegotiation.ts +++ b/packages/client/src/client/versionNegotiation.ts @@ -27,7 +27,7 @@ import { import { UnauthorizedError } from './auth'; import { isAuthSeamEscape } from './authSeam'; -import { readInvalidReplyEscape } from './invalidReplySeam'; +import { readInvalidReplyEscape, SERVER_DISCOVER_PROBE_ID_PREFIX } from './invalidReplySeam'; import type { ProbeEnvironment, ProbeOutcome, ProbeTransportKind, ProbeVerdict } from './probeClassifier'; import { classifyProbeOutcome } from './probeClassifier'; @@ -255,10 +255,11 @@ class ProbeWindow { /** * Send one probe request and await its reply. Probe ids are strings, so they - * never collide with Protocol's numeric ids (e.g. on a shared stdio pipe). + * never collide with Protocol's numeric ids (e.g. on a shared stdio pipe) — + * and the reserved prefix is what transport rows scoped to the probe key on. */ async exchange(buildRequest: (id: string) => JSONRPCRequest, timeoutMs: number): Promise { - const id = `server-discover-probe-${++this._probeCounter}`; + const id = `${SERVER_DISCOVER_PROBE_ID_PREFIX}${++this._probeCounter}`; return new Promise(resolve => { let settled = false; const settle = (reply: RawProbeReply) => { @@ -416,14 +417,16 @@ function normalizeReply(reply: RawProbeReply, timeoutMs: number): ProbeOutcome { // JSON-RPC reply — provenance stamped at the transport's parse // boundary (an off-spec error body, empty/unparseable JSON, or a // 202 accepted-without-reply). Not a network condition: the - // classifier owns the verdict. - return { kind: 'invalid-reply', body: invalidReply.body }; + // classifier owns the verdict. The stamped original error rides + // along as the outcome's cause for the modes that cannot fall + // back and must diagnose the broken reply. + return { kind: 'invalid-reply', body: invalidReply.body, cause: error }; } if (error instanceof SdkError && error.code === SdkErrorCode.ClientHttpUnexpectedContent) { // A 2xx answer in a non-MCP content type (a proxy's HTML error // page, text/plain, a missing content-type): the same completed- // exchange evidence, with no JSON-RPC body to read. - return { kind: 'invalid-reply' }; + return { kind: 'invalid-reply', cause: error }; } return { kind: 'network-error', error }; } @@ -520,18 +523,37 @@ export async function negotiateEra( continue; } case 'legacy': { - // A closed outcome carries its own cause — diagnostics must - // name the close instead of implying a server/discover - // verdict that never happened. + // Outcomes that reached the legacy verdict without the + // server answering `server/discover` carry their own cause: + // diagnostics on the modes that cannot fall back must name + // the close, the HTTP failure, or the broken reply instead + // of implying a discover verdict that never happened — a + // transient 503 mid-deploy is not "the server lacks the + // pin" — and carry the outcome's evidence (HTTP status and + // body, the stamped original error) on the typed error's + // `data`. const closedCause = outcome.kind === 'closed' ? 'the connection closed during the server/discover probe' : undefined; + const answerCause = + outcome.kind === 'http-error' + ? `the server answered the probe with HTTP ${outcome.status}` + : outcome.kind === 'invalid-reply' + ? 'the probe reply was not a valid JSON-RPC message' + : undefined; + const causeData = + outcome.kind === 'http-error' + ? { status: outcome.status, statusText: outcome.statusText, text: outcome.body } + : outcome.kind === 'invalid-reply' + ? { cause: outcome.cause, body: outcome.body } + : undefined; if (negotiation.kind === 'pin') { throw new SdkError( SdkErrorCode.EraNegotiationFailed, closedCause === undefined ? `Version negotiation failed: the server did not offer pinned protocol version ${negotiation.version} ` + - `via server/discover (no fallback in pin mode)` + `via server/discover (${answerCause === undefined ? '' : `${answerCause}; `}no fallback in pin mode)` : `Version negotiation failed: ${closedCause} before the server offered ` + - `pinned protocol version ${negotiation.version} (no fallback in pin mode)` + `pinned protocol version ${negotiation.version} (no fallback in pin mode)`, + causeData ); } if (!negotiation.fallbackAvailable) { @@ -540,10 +562,12 @@ export async function negotiateEra( throw new SdkError( SdkErrorCode.EraNegotiationFailed, closedCause === undefined - ? 'Version negotiation failed: the server gave no modern evidence and this client supports no ' + + ? 'Version negotiation failed: the server gave no modern evidence' + + `${answerCause === undefined ? '' : ` (${answerCause})`} and this client supports no ` + 'pre-2026-07-28 protocol version to fall back to' : `Version negotiation failed: ${closedCause} and this client supports no ` + - 'pre-2026-07-28 protocol version to fall back to' + 'pre-2026-07-28 protocol version to fall back to', + causeData ); } if (closedCause !== undefined && deps.disposableProbe !== true) { diff --git a/packages/client/test/client/versionNegotiation.test.ts b/packages/client/test/client/versionNegotiation.test.ts index cf4bf59ded..4042d04e6f 100644 --- a/packages/client/test/client/versionNegotiation.test.ts +++ b/packages/client/test/client/versionNegotiation.test.ts @@ -1440,18 +1440,30 @@ describe('probe invalid-reply classification (stamped escapes from the transport await client.close(); }); - test('pin mode: an invalid reply still fails loudly — no fallback', async () => { + test('pin mode: an invalid reply still fails loudly — no fallback, the cause named and the evidence on data', async () => { const stamped = markInvalidReplyEscape(new Error('validation failed'), JSON.parse(OC39354_BODY)); const transport = new InvalidReplyTransport(stamped); const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } }); - await expect(client.connect(transport)).rejects.toSatisfy( - error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + const rejection: unknown = await client.connect(transport).then( + () => undefined, + (error: unknown) => error ); + expect(rejection).toBeInstanceOf(SdkError); + const error = rejection as SdkError; + expect(error.code).toBe(SdkErrorCode.EraNegotiationFailed); + // Diagnostic fidelity: the message names the broken reply — a server + // answering garbage must stay distinguishable from a server that + // genuinely lacks the pin — and data carries the stamped original + // error plus the offending body. + expect(error.message).toContain(`the server did not offer pinned protocol version ${MODERN}`); + expect(error.message).toContain('the probe reply was not a valid JSON-RPC message'); + expect(error.data).toMatchObject({ body: JSON.parse(OC39354_BODY) }); + expect((error.data as { cause?: unknown }).cause).toBe(stamped); expect(requests(transport.sent).some(r => r.method === 'initialize')).toBe(false); }); - test('modern-only client: typed error instead of a fallback the client cannot run', async () => { + test('modern-only client: typed error instead of a fallback the client cannot run, same cause fidelity', async () => { const stamped = markInvalidReplyEscape(new Error('validation failed'), JSON.parse(OC39354_BODY)); const transport = new InvalidReplyTransport(stamped); const client = new Client( @@ -1459,9 +1471,61 @@ describe('probe invalid-reply classification (stamped escapes from the transport { versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: [MODERN] } ); - await expect(client.connect(transport)).rejects.toSatisfy( - error => error instanceof SdkError && error.code === SdkErrorCode.EraNegotiationFailed + const rejection: unknown = await client.connect(transport).then( + () => undefined, + (error: unknown) => error + ); + expect(rejection).toBeInstanceOf(SdkError); + const error = rejection as SdkError; + expect(error.code).toBe(SdkErrorCode.EraNegotiationFailed); + expect(error.message).toContain('the server gave no modern evidence (the probe reply was not a valid JSON-RPC message)'); + expect((error.data as { cause?: unknown }).cause).toBe(stamped); + expect(requests(transport.sent).some(r => r.method === 'initialize')).toBe(false); + }); + + test('pin mode: a 5xx probe answer names the status and carries it on data — a transient 503 is not a missing pin', async () => { + const httpError = new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, 'Error POSTing to endpoint: mid-deploy', { + status: 503, + statusText: 'Service Unavailable', + text: 'mid-deploy' + }); + const transport = new InvalidReplyTransport(httpError); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: { pin: MODERN } } }); + + const rejection: unknown = await client.connect(transport).then( + () => undefined, + (error: unknown) => error ); + expect(rejection).toBeInstanceOf(SdkError); + const error = rejection as SdkError; + expect(error.code).toBe(SdkErrorCode.EraNegotiationFailed); + expect(error.message).toContain('the server answered the probe with HTTP 503'); + expect(error.message).toContain('no fallback in pin mode'); + expect(error.data).toMatchObject({ status: 503, statusText: 'Service Unavailable', text: 'mid-deploy' }); + expect(requests(transport.sent).some(r => r.method === 'initialize')).toBe(false); + }); + + test('modern-only client: a 5xx probe answer names the status and carries it on data too', async () => { + const httpError = new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, 'Error POSTing to endpoint: mid-deploy', { + status: 503, + statusText: 'Service Unavailable', + text: 'mid-deploy' + }); + const transport = new InvalidReplyTransport(httpError); + const client = new Client( + { name: 'c', version: '0' }, + { versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: [MODERN] } + ); + + const rejection: unknown = await client.connect(transport).then( + () => undefined, + (error: unknown) => error + ); + expect(rejection).toBeInstanceOf(SdkError); + const error = rejection as SdkError; + expect(error.code).toBe(SdkErrorCode.EraNegotiationFailed); + expect(error.message).toContain('the server gave no modern evidence (the server answered the probe with HTTP 503)'); + expect(error.data).toMatchObject({ status: 503 }); expect(requests(transport.sent).some(r => r.method === 'initialize')).toBe(false); }); }); @@ -1613,6 +1677,47 @@ describe('wire-real invalid probe replies over StreamableHTTPClientTransport (oc await client.close(); }); + test('202 answering the post-connect discover() (numeric Protocol id) is never EraNegotiationFailed — the 202 row is probe-scoped', async () => { + // The probe is recognized by its reserved string id prefix; the public + // mid-session Client.discover() goes through Protocol.request with a + // numeric id, so a misbehaving server 202-ing it must keep the pre-fix + // pending-until-RequestTimeout behavior — never the probe's stamped + // EraNegotiationFailed on an established modern connection. + const fetchImpl = (async (_input: string | URL | Request, init?: RequestInit): Promise => { + if ((init?.method ?? 'GET') !== 'POST') { + return new Response('Method Not Allowed', { status: 405 }); + } + const parsed = JSON.parse(String(init?.body)) as { id?: string | number; method?: string }; + if (parsed.method === 'server/discover' && typeof parsed.id === 'string') { + // The connect-time probe: definitive modern evidence. + return Response.json({ + jsonrpc: '2.0', + id: parsed.id, + result: { supportedVersions: [MODERN], capabilities: {} } + }); + } + // Everything else — including the mid-session discover() with its + // numeric id — is accepted without a reply. + return new Response(null, { status: 202 }); + }) as typeof fetch; + const transport = new StreamableHTTPClientTransport(new URL('https://rude.example.com/mcp'), { fetch: fetchImpl }); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + await client.connect(transport); + expect(client.getProtocolEra()).toBe('modern'); + + const rejection: unknown = await client.discover({ timeout: 50 }).then( + () => undefined, + (error: unknown) => error + ); + expect(rejection).toBeInstanceOf(SdkError); + const error = rejection as SdkError; + expect(error.code).not.toBe(SdkErrorCode.EraNegotiationFailed); + expect(error.code).toBe(SdkErrorCode.RequestTimeout); + expect(error.message).not.toContain('accepted the server/discover probe'); + await client.close(); + }); + test('control: a network failure on the probe still rejects typed — never a fallback', async () => { const netError = new TypeError('fetch failed'); const fetchMock = (async (): Promise => { diff --git a/packages/core-internal/src/errors/sdkErrors.ts b/packages/core-internal/src/errors/sdkErrors.ts index 48ed6d6f52..6209b16971 100644 --- a/packages/core-internal/src/errors/sdkErrors.ts +++ b/packages/core-internal/src/errors/sdkErrors.ts @@ -69,7 +69,11 @@ export enum SdkErrorCode { * a network failure (a typed connect error, never an era verdict). * * Negotiation-phase only: this code is never used once an era is - * established. Auth walls never carry it: a 401/403 rejecting the probe + * established — even transport rows that raise it are keyed to the + * probe's reserved `server-discover-probe-` string request-id prefix + * (e.g. the Streamable HTTP 202-accepted row), so the public post-connect + * `Client.discover()` request can never surface it. + * Auth walls never carry it: a 401/403 rejecting the probe * uses {@linkcode ClientHttpAuthentication} / {@linkcode ClientHttpForbidden} * instead, so era-recovery flows keyed on this code (e.g. cached-verdict * gateways) can never persist a verdict for an unauthorized exchange.