Skip to content

fix(client): fall back to initialize when the discover probe gets a completed non-modern answer - #2571

Open
claude[bot] wants to merge 3 commits into
mainfrom
fix/probe-malformed-error-fallback
Open

fix(client): fall back to initialize when the discover probe gets a completed non-modern answer#2571
claude[bot] wants to merge 3 commits into
mainfrom
fix/probe-malformed-error-fallback

Conversation

@claude

@claude claude Bot commented Jul 28, 2026

Copy link
Copy Markdown

Requested by Felix Weinberger

Before

A v2 client with versionNegotiation: { mode: 'auto' } hard-failed connect() with SdkError: Version negotiation probe failed (or burned the full probe timeout) whenever the server answered the server/discover probe with a completed HTTP exchange that wasn't a valid modern reply — even though the server had just demonstrated it doesn't speak server/discover and a plain initialize would have succeeded. Concretely:

  • 200 + malformed JSON-RPC error — the in-the-wild report, v1.18.8 remote MCP fails: server/discover probe rejected by server (-32700), no fallback to classic initialize anomalyco/opencode#39354: a vendor server answers unknown methods with {"error":{"code":-32700,"message":"Parse Error"},"id":null} (a legal JSON-RPC 2.0 parse-error reply, but not representable by the SDK's strict message schema because of id: null). The probe died in schema validation and connect rejected with the raw zod issues — no fallback. Note the asymmetry: the same body on a 404 already fell back via the classifier's lenient 4xx body parse; only the 200 path hard-stopped.
  • 200 + empty or unparseable JSON body, and 200 in a non-MCP content type (a proxy's HTML error page, text/plain, missing content-type) — same hard stop, surfaced as an opaque "network" failure.
  • 202 Accepted to the probe — treated as silence: the client waited out the full probe timeout (default 60s) and then rejected with RequestTimeout, despite the 202 being a definitive "no reply is coming".
  • Any 5xx — typed hard stop, including a 500 carrying a perfectly well-formed -32601 body (some frameworks map JSON-RPC errors to 500).

After

Per Felix's direction ("always try initialize rather than cut short after discover" for completed exchanges; 5xx included — "lots of poorly behaved weird servers out there"), the rule is now:

Any completed non-auth HTTP exchange whose answer is not a valid modern reply is legacy evidence → fall back to initialize. Auth statuses (401/403), network-level failures (refused/DNS/reset), and true silence (HTTP probe timeout) still reject with typed errors, and pin mode still never falls back.

All five classes above now fall back and connect against a working legacy server, matching the long-standing unparseable-4xx rule. The 202 case falls back immediately — no probe-timeout wait. Repro'd end-to-end against the exact opencode#39354 wire shape (200 + -32700 + id:null → fallback initialize → connected, era legacy).

How

  • packages/client/src/client/invalidReplySeam.ts (new, internal): an identity-preserving provenance stamp in the style of the fix(client): treat HTTP 401/403 on the negotiation probe as auth failures, not legacy evidence #2564 auth seam (Symbol.for, survives bundler double-installs). The transport stamps errors born between fetch resolution and onmessage — previously indistinguishable from network errors by the time the classifier saw them — carrying the offending parsed body when there is one.
  • streamableHttp.ts: stamps the strict JSONRPCMessageSchema validation failure with the parsed body (2xx JSON branch), stamps response.json() parse failures (empty/invalid JSON), and turns a 202-to-the-probe-request into an immediate stamped throw (scoped to method === 'server/discover'; every other flow through the 202 branch is unchanged). Wrong/missing content-type needed no transport change — it already throws typed SdkError(ClientHttpUnexpectedContent).
  • versionNegotiation.ts normalizeReply: reads the stamp (and matches ClientHttpUnexpectedContent) into a new normalized outcome, { kind: 'invalid-reply', body? }, instead of network-error.
  • probeClassifier.ts: new invalid-reply row — a leniently readable JSON-RPC error member classifies like an in-band error first (so the -32022 corrective/select-and-continue rows keep working even off a malformed reply), everything else is the conservative legacy fallback. The 5xx special case from fix(client): treat HTTP 401/403 on the negotiation probe as auth failures, not legacy evidence #2564 is removed: all non-auth HTTP rejections (remaining 4xx and 5xx) now flow through the same body-parse-then-legacy path. 401/403 handling is untouched and still ranks above the body parse.
  • Docs: docs/protocol-versions.md and docs/migration/support-2026-07-28.md updated with the completed-exchange rule (and the kept exceptions); changeset added (patch, @modelcontextprotocol/client).
  • Tests (all passing, 820 client + 371 integration): classifier rows for the exact oc#39354 shape, extra/unknown members, unreadable error members, -32022-via-malformed-reply, bodiless invalid replies; wire-real regressions through the real StreamableHTTPClientTransport with a scripted fetch for every class (oc#39354 body, empty body, invalid JSON, HTML, wrong CT, missing CT, 202-immediacy with a timing assertion, bare 500, 500+-32601 body, 502 HTML); pin-mode and modern-only gates still refuse to fall back; controls pinning that network failures and the HTTP probe timeout still reject.

Open question for review: 5xx and cached era verdicts

The one argument against 5xx-fallback was fleet-level caching: a modern server having a transient 5xx moment gets handshaken as legacy for that connection, and a host using the gateway guide's connect({ prior }) recipe could persist that verdict. The SDK itself never emits a cacheable verdict object — hosts synthesize { kind: 'legacy' } from the absence of getDiscoverResult() — so marking the 5xx path non-cacheable would require new public API (verdict provenance on Client). This PR ships the plain fallback and leans on the guide's existing mitigation (date cached legacy verdicts and let them expire), with notes added in the classifier comment and both docs pages. If reviewers want a first-class guard (e.g. exposing why the era is legacy), happy to follow up.

Fixes the SDK side of anomalyco/opencode#39354.


Generated by Claude Code

…ompleted non-modern answer

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EfMZraQUoWyeSBT2HaQ5v
@changeset-bot

changeset-bot Bot commented Jul 28, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 573da02

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/client Patch
@modelcontextprotocol/core Patch
@modelcontextprotocol/server Patch
@modelcontextprotocol/server-legacy Patch
@modelcontextprotocol/codemod Patch
@modelcontextprotocol/core-internal Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jul 28, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2571

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2571

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2571

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2571

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2571

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2571

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2571

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2571

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2571

commit: 573da02

Comment thread packages/client/src/client/streamableHttp.ts
Comment thread packages/client/src/client/probeClassifier.ts
Comment on lines 1175 to 1192
);
} 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);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The new completed-exchange fallback rule doesn't cover the probe answered with 200 + text/event-stream: a schema-invalid reply delivered as an SSE event (e.g. the exact oc#39354 id: null body) is routed only to onerror inside _handleSseStream — no markInvalidReplyEscape stamp — and a stream that ends with no reply message fires nothing the probe observes, so both burn the full probe timeout (default 60s) and reject with SdkError(RequestTimeout) instead of falling back. Meanwhile the prose added to docs/protocol-versions.md and docs/migration/support-2026-07-28.md states the rule categorically ("Any completed non-auth HTTP exchange whose answer is not a valid modern reply is legacy evidence") with no content-type qualifier. Either extend the seam to the SSE branch (at minimum stream-end-without-reply) or qualify the docs.

Extended reasoning...

The bug. This PR's invalid-reply seam covers three escape points: the 2xx application/json branch (parseJsonResponseMessage plus the response.json() catch at streamableHttp.ts:1178-1192), the probe-scoped 202 branch (streamableHttp.ts:1129-1143), and the ClientHttpUnexpectedContent match in normalizeReply. But a Streamable HTTP server may legitimately answer the probe POST with 200 + text/event-stream, and on that branch the reply is parsed inside _handleSseStream (JSONRPCMessageSchema.parse(JSON.parse(event.data)), streamableHttp.ts:787), whose catch routes the strict-schema failure only to this.onerror — no markInvalidReplyEscape stamp anywhere on this path.\n\nWhy the error never reaches the classifier. For the SSE branch, _send() resolves as soon as _handleSseStream is attached (it's fire-and-forget), so the probe's transport.send() promise has already resolved successfully — there is no throw for normalizeReply's send-error arm to inspect. The ProbeWindow onerror handler deliberately just forwards to the saved handler without settling the pending exchange (versionNegotiation.ts: "the probe resolves via a send failure, the close signal, or the timeout"). So an SSE-delivered invalid reply produces exactly: onerror fires, the probe keeps waiting, and the classifier's HTTP-timeout row eventually rejects connect() with SdkError(RequestTimeout).\n\nStep-by-step proof (mirror of the wire-real test suite, with one header changed):\n1. Client connects with versionNegotiation: { mode: 'auto' }; the probe POSTs server/discover.\n2. Server answers 200 with content-type: text/event-stream and emits one SSE event whose data is the oc#39354 body {"error":{"code":-32700,"message":"Parse Error"},"id":null,"jsonrpc":"2.0"}.\n3. _send() takes the responseMediaType === 'text/event-stream' branch (streamableHttp.ts:1165-1175), calls _handleSseStream, and returns — the probe's send resolved.\n4. _handleSseStream parses the event: id: null fails JSONRPCMessageSchema; the catch calls this.onerror?.(error) (streamableHttp.ts:797) — unstamped.\n5. The probe window forwards onerror and keeps the exchange pending; after timeoutMs (default 60s) the timeout row rejects with SdkError(RequestTimeout). No initialize is ever attempted.\n\nThe same outcome holds for an SSE stream that completes with no reply message at all: a POST stream has isReconnectable=false and no priming event, so stream end calls onRequestStreamEnd?.() — which the probe never supplies — and produces silence → timeout. That is the exact SSE analogue of the empty application/json body, which this PR does make fall back. Note the asymmetry the seam creates: a valid -32601 error delivered over SSE reaches onmessage and falls back correctly, so the gap is precisely the schema-invalid/empty SSE answers — the failure class the PR migrates for JSON.\n\nDocs contradiction. The prose added in this same PR to docs/protocol-versions.md and docs/migration/support-2026-07-28.md states the rule with no content-type qualifier: "any COMPLETED non-auth HTTP exchange whose answer is not a valid modern reply is legacy evidence and falls back to initialize", explicitly listing "a 2xx carrying a JSON-RPC error body the strict schema rejects." On the SSE path the code does not ship that behavior — this hits the repo's Documentation recurring catch (prose the diff doesn't back) and the Completeness catch (a partial migration leaving a sibling content-type path with the very burned-timeout failure the PR fixes).\n\nWhy nit, not blocking. No in-the-wild server of this shape is evidenced — the oc#39354 vendor answered in application/json, and deployed 2025 SDK servers reject the unknown probe at the HTTP layer before ever opening SSE on the POST. The SSE-path behavior is unchanged from before this PR (a loud typed timeout, not a silent wrong era verdict). And an immediate fallback on one malformed SSE frame would itself be questionable — the stream may still carry the valid reply later, and modern servers legitimately answer the probe over SSE. The minimal fix is qualifying the doc prose to "JSON-answered exchanges"; a fuller fix would stamp the SSE parse failure (or at least treat stream-end-without-any-reply as a bodiless invalid-reply escape when the pending request is the probe).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took the docs-qualification route here, per the minimal fix suggested above: docs/protocol-versions.md and docs/migration/support-2026-07-28.md now scope the completed-exchange rule to answers delivered as a completed HTTP body, and state explicitly that a probe answered 200 + text/event-stream is classified by the JSON-RPC reply the stream delivers — a stream carrying only a schema-invalid reply, or ending without one, reads as silence and still runs out the probe timeout, as before this PR.

Extending the seam to the SSE branch is deferred deliberately: modern servers legitimately answer the probe over SSE, so a malformed frame mid-stream is not safe era evidence (the stream may still carry the valid reply later), and no in-the-wild server of this shape is evidenced — the oc#39354 vendor answered in application/json, and deployed 2025 servers reject the unknown probe at the HTTP layer before opening SSE on the POST. If such a shape ever surfaces, treating probe-stream end-without-reply as a bodiless invalid-reply escape is the natural follow-up.


Generated by Claude Code

…fallback

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EfMZraQUoWyeSBT2HaQ5v

@claude claude Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the inline findings, this run also re-examined the switch from response.json() to an unguarded response.text() read in the 2xx JSON branch and confirmed it is correct: a text() rejection (mid-body reset) propagates unstamped to the network-error row, so the prior review round's truncated-body-classifies-as-legacy concern is resolved — backed by the new mid-body socket-destroy integration test. Also checked schema-valid-but-probe-unmatched direct JSON answers (wrong/fixed id, empty batch): they time out today exactly as before this PR, so no runtime regression — only the docs-overbreadth nit noted inline.

Extended reasoning...

Bugs (three nits) were found and are posted as inline comments, so no approval/deferral verdict is given. This note records only what was additionally examined and ruled out this run: (1) the unguarded response.text() read — verified that mid-body network failures stay unstamped and reject typed, confirming the fix for the previous round's blocking finding; (2) schema-valid replies with non-matching ids and empty batch bodies — pre-existing timeout behavior, not a regression introduced here. Informational only; not a guarantee of correctness.

Comment thread packages/client/src/client/versionNegotiation.ts
Comment thread docs/protocol-versions.md Outdated
Comment thread packages/client/src/client/streamableHttp.ts
…on pin/modern-only rejections

Address review round 2 on the completed-exchange fallback:

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013EfMZraQUoWyeSBT2HaQ5v

@claude claude Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

This review may be incomplete: some analysis steps could not run due to a temporary API capacity limit.

All findings from my earlier review rounds are now addressed in the diff (the mid-body text()-first read, the probe-id-prefix scoping on the 202 gate, the pin/modern-only cause fidelity, and the docs qualifications), and this pass found no new issues — but this changes connect-time negotiation semantics and the PR itself poses an open design question (5xx-as-legacy-evidence vs. cached era verdicts), so it warrants a human decision.

What was reviewed this pass:

  • the invalid-reply seam end to end (stamp sites in streamableHttp.ts, normalizeReply, the new classifier row) against the documented rule and its stated exceptions (auth, network, silence)
  • the answerCause parenthetical on pin/modern-only throws — a candidate contradiction with the new troubleshooting prose was investigated and ruled out
  • the wire-real test matrix covers every newly-falling-back class plus controls for network failure, probe timeout, and post-connect discover()
Extended reasoning...

Overview

The PR reroutes five classes of completed-but-broken server/discover probe answers (schema-invalid 2xx bodies, empty/unparseable JSON, non-MCP content types, 202-to-the-probe, and all non-auth 5xx) from hard connect failures into the legacy initialize fallback. It adds an internal provenance-stamp module (invalidReplySeam.ts), a new invalid-reply classifier outcome, cause-carrying diagnostics on the pin/modern-only rejection paths, and updates four docs pages plus a changeset. Roughly 340 lines of new unit tests and an integration test for the mid-body-reset control accompany it.

Security risks

Low. No auth, crypto, or permission logic is touched; the 401/403 typed-auth-failure paths are explicitly preserved and pinned by tests. The main risk class is misclassification — a transient server failure being handshaken as legacy — which is a robustness/fleet-caching concern rather than a security one, and the PR documents the mitigation (dated cached legacy verdicts) in three places.

Level of scrutiny

High. This is client connect-path logic in a published SDK, and it inverts a previously deliberate rule (5xx was 'never era evidence'; it now selects the legacy fallback). The PR description itself flags the 5xx/cached-verdict tradeoff as an open question for reviewers, and the repo's own review conventions put design justification first — that call (made per maintainer direction on the linked issue, but still a semantic inversion) belongs to a human maintainer, not a bot approval.

Other factors

My three earlier review rounds surfaced one red finding (mid-body network failures being stamped as legacy evidence) and several yellow ones (202 gate matching post-connect discover(), missing diagnostics on pin-mode throws, stale troubleshooting.md 5xx bullet, over-categorical docs prose); the current diff addresses all of them, each with a pinning test or docs fix — including an integration test that destroys the socket mid-body to prove the network path stays typed. This pass found nothing new; the one finder candidate (the answerCause parenthetical firing for ordinary 4xx declines) was examined and refuted. Test coverage is thorough and includes negative controls, so the remaining open item is purely the design question above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant