Skip to content

fix(client): fall back from discover for any non-modern error - #1133

Open
ip2a wants to merge 1 commit into
modelcontextprotocol:mainfrom
ip2a:fix/auto-lifecycle-fallback
Open

fix(client): fall back from discover for any non-modern error#1133
ip2a wants to merge 1 commit into
modelcontextprotocol:mainfrom
ip2a:fix/auto-lifecycle-fallback

Conversation

@ip2a

@ip2a ip2a commented Aug 5, 2026

Copy link
Copy Markdown

Fixes #1040.

Problem

ClientLifecycleMode::Auto only fell back to the legacy initialize
handshake when server/discover failed with -32601 (METHOD_NOT_FOUND).
Legacy servers commonly reject an unknown pre-initialize request with
other implementation-defined errors (-32600, -32602, session-middleware
errors), so Auto broke against servers Initialize could have reached.

Approach

Replace the Err(_) + modern-code carve-out with a single classification,
ClientInitializeError::indicates_legacy_server, placed next to the
existing auth_challenge / is_authorization_required:

  • Fall back only on legacy signals: a non-modern JSON-RPC error, a closed
    connection, an unexpected response shape, or a non-authorization transport
    failure.
  • Surface everything an initialize retry cannot resolve: modern-era
    rejections (MISSING_REQUIRED_CLIENT_CAPABILITY, HEADER_MISMATCH), a
    negotiated version mismatch, an authorization or scope gate, and
    client-side state (NoPreferredProtocolVersion, Cancelled).

The match is exhaustive, so a new ClientInitializeError variant forces a
decision instead of being swept into the fallback. is_authorization_failure
fills the 403 gap left by the 401-only is_authorization_required.

A silently legacy server that ignores the probe and hangs expect_response
is a separate concern (needs a discover timeout); tracked in #1142.

Tests

test_legacy_server_classification.rs pins the classification per variant
(legacy JSON-RPC, modern rejection, 401/403/local-OAuth, client state,
closed/unexpected response, version mismatch). Existing lifecycle and auth
classification tests stay green.

@ip2a
ip2a requested a review from a team as a code owner August 5, 2026 07:44
@github-actions github-actions Bot added T-test Testing related changes T-core Core library changes T-service Service layer changes labels Aug 5, 2026
Comment thread crates/rmcp/src/service/client.rs Outdated
// Fall back to the legacy `initialize` handshake. The 2026-07-28
// backward-compatibility guidance is explicit that this MUST NOT
// be keyed to one specific error code.
Err(_) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Err(_) also catches 401 and 403, as well as client-side failures like NoPreferredProtocolVersion. Neither indicates that the peer is legacy, but Auto now attempts initialize and may hide the original actionable error.

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.

Fixed in the new push — no more Err(_). indicates_legacy_server
classifies each variant, so 401/403/missing-OAuth (new
is_authorization_failure) and NoPreferredProtocolVersion / Cancelled
all surface instead of falling back. Match is exhaustive, so a new variant
has to be classified explicitly.

Comment thread crates/rmcp/src/service/client.rs Outdated
// must not trigger a legacy fallback. Surface the error.
Err(error) if is_modern_server_error(&error) => return Err(error),
// Any other outcome — `-32601`, `-32602`, `-32600`, another
// implementation-defined error, or no response at all — means

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

discover_startup waits in expect_response until a message comes in or the transport closes. So, if a legacy server is open but silently ignores server/discover, it never reaches this branch.

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.

Right, didn't fix that here. expect_response blocks until the transport
closes or a message arrives, so a server that ignores server/discover
never reaches the fallback. Needs a discover probe timeout, which changes
discover_startup / expect_response — I'll open a separate issue so it
stays out of this PR.

`ClientLifecycleMode::Auto` only fell back from `server/discover` on
`-32601`, so legacy servers that reject the probe with other codes
(`-32600`, `-32602`, session-middleware errors) failed to connect even
though `initialize` would have succeeded.

The 2026-07-28 backward-compatibility guidance is explicit that the
fallback MUST NOT be keyed to one specific error code, but it also only
applies to failures that signal a legacy server. Add
`ClientInitializeError::indicates_legacy_server`, which classifies a
discover failure by what it says about the peer:

- a JSON-RPC error is legacy unless it is a modern-era rejection
  (`MISSING_REQUIRED_CLIENT_CAPABILITY` or `HEADER_MISMATCH`; version
  negotiation is already handled inside `discover_startup`, so
  `UNSUPPORTED_PROTOCOL_VERSION` never reaches here);
- a closed connection, an unexpected response shape, or a non-authorization
  transport failure is treated as a legacy server that did not engage the
  probe;
- an authorization or scope gate (HTTP 401/403, missing local OAuth), a
  modern version mismatch, or a client-side condition
  (`NoPreferredProtocolVersion`, `Cancelled`) is surfaced, because an
  `initialize` retry cannot resolve it and would mask the actionable error.

The fallback is keyed on this classification, and the new
`is_authorization_failure` helper fills the 403 gap left by the existing
401-only `is_authorization_required`. The classification match is
exhaustive on `ClientInitializeError`, so adding a variant forces a
decision instead of being silently swept into the fallback.

A silently legacy server that ignores the probe entirely still stalls
`expect_response`; that requires a discover timeout and is out of scope
here.

Fixes modelcontextprotocol#1040.
@ip2a
ip2a force-pushed the fix/auto-lifecycle-fallback branch from 1e29393 to 2af076e Compare August 6, 2026 03:03
@ip2a

ip2a commented Aug 6, 2026

Copy link
Copy Markdown
Author

Redesigned per your feedback. Replaced the Err(_) + modern-code carve-out
with ClientInitializeError::indicates_legacy_server: an exhaustive match
that falls back only on legacy signals (non-modern JSON-RPC error, closed
connection, unexpected response, non-auth transport error) and surfaces the
rest (modern rejections, auth/scope gate, client state). Added
is_authorization_failure for the 403 gap; test_legacy_server_classification
pins each variant.

Three calls I'd like your input on:

  1. ConnectionClosed falls back. On stdio the transport is dead so
    legacy_startup's send fails and masks the original error; on HTTP a new
    initialize POST can still work. Kept fallback, lean that way but ok to
    surface instead.
  2. ExpectedInitResponse / ExpectedInitResult / ConflictInitResponseId
    fall back — rare, transport still alive, seemed worth a try.
  3. Non-auth TransportError (IO) is lumped with non-JSON 400; both fall
    back. Splitting needs a per-transport downcast, didn't seem worth it.

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

Labels

T-core Core library changes T-service Service layer changes T-test Testing related changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ClientLifecycleMode::Auto does not fall back for deployed legacy-server responses

2 participants