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 that previously worked.

Approach

Classification happens inside discover_startup, where the full context
(request id, response correlation, transport state) is still available — not
after the fact on ClientInitializeError, where that context is gone.

discover_startup returns a DiscoverOutcome:

  • Modern — discover succeeded.
  • Legacy(error) — the probe received a complete, correlated JSON-RPC error
    whose code is not a modern-era rejection. The transport delivered a full
    response and is ready for the next request, so a legacy initialize can
    follow on the same connection.
  • Err(error) — everything else (transport failure, uncorrelated response,
    modern rejection, client-side state). Surfaced, not retried.

Auto simply matches the outcome. No methods on ClientInitializeError, no
downcast, no transport-specific types leaking into the lifecycle layer.

Two additional fixes that fall out naturally:

  • Response correlation: expect_response now checks the request id on
    both success and error responses. Previously error responses skipped id
    correlation entirely. A new UncorrelatedErrorResponse variant surfaces
    responses that cannot be tied to the request.

  • Fallback failure preservation: when both discover and the legacy
    fallback fail, a LegacyFallbackFailed compound error preserves both
    phases instead of discarding the discover error.

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

Tests

The existing lifecycle tests verify the behavior end-to-end: -32601,
-32600, and -32602 discover responses trigger fallback; -32021 and
-32020 are surfaced. The client-initialization test was fixed to echo the
real request id (it previously hardcoded 1 against an id provider that
starts at 0, which only worked because error responses were never
correlated).

Scope

Non-JSON HTTP responses (e.g. a legacy server returning a plain-text 422) are
not covered by this PR. Recognizing them as a legacy signal requires the
transport layer to carry a structured HTTP-status signal across the transport
boundary, which is an architectural change best discussed separately.

@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
Comment thread crates/rmcp/src/service/client.rs Outdated
@ip2a
ip2a force-pushed the fix/auto-lifecycle-fallback branch 2 times, most recently 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.

Comment thread crates/rmcp/src/service/client.rs Outdated
Comment thread crates/rmcp/src/service/client.rs Outdated
Comment thread crates/rmcp/src/service/client.rs Outdated
@ip2a
ip2a force-pushed the fix/auto-lifecycle-fallback branch from 2af076e to a81d9ed Compare August 8, 2026 01:24
@ip2a

ip2a commented Aug 8, 2026

Copy link
Copy Markdown
Author

Rewrote this to classify inside discover_startup instead of on the error
type. It now returns DiscoverOutcome::Modern or DiscoverOutcome::Legacy,
and Auto just matches on it — no indicates_legacy_server, no downcast,
no transport types in the lifecycle layer.

The three open review comments are addressed by the rewrite:

  • ConnectionClosed: doesn't enter the fallback path at all (it's not a
    JsonRpcError, so discover_startup returns Err and Auto surfaces it)
  • mismatched response id: expect_response now correlates ids on error
    responses too; UncorrelatedErrorResponse is surfaced, not retried
  • non-JSON HTTP responses: out of scope for this PR. Needs the transport
    to carry a structured signal, which I'd rather discuss separately

return Err(match &error.id {
Some(id) if expected_id.matches_response_id(id) => {
ClientInitializeError::JsonRpcError(error.error)
}

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.

Response-scoped errors without an ID should remain available to the legacy classifier.

Suggested change
}
}
None => ClientInitializeError::JsonRpcError(error.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.

Done — absent-id errors now go to JsonRpcError, only a mismatched Some(id) surfaces as UncorrelatedErrorResponse.

Cancelled,

#[error("discover and legacy initialize both failed")]
LegacyFallbackFailed {

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.

In ClientInitializeError::auth_challenge and ClientInitializeError::is_authorization_required, which drives reactive OAuth, the new wrapper hides fallback 401/403 challenges and causes authorization-required fallback errors to return false. Recurse into the fallback error so callers keep the documented classification behavior.

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 — both methods now recurse into the fallback phase of LegacyFallbackFailed.

@ip2a
ip2a force-pushed the fix/auto-lifecycle-fallback branch from a81d9ed to f3885a5 Compare August 9, 2026 01:08
`ClientLifecycleMode::Auto` only fell back from `server/discover` on
`-32601`, so legacy servers that reject the probe with other codes
(`-32600`, `-32602`, implementation-defined errors) failed to connect
even though `initialize` would have succeeded.

The previous attempt (indicates_legacy_server) classified the failure
after the fact by reverse-engineering the error type. This rewrite moves
the classification into `discover_startup` itself, where the full context
(request id, response correlation, transport state) is still available.

`discover_startup` now returns `DiscoverOutcome`: `Modern` on success,
`Legacy(error)` when the probe received a complete, correlated JSON-RPC
error whose code is not a modern-era rejection. Every other failure
becomes `Err`, so `Auto` simply matches the outcome — no methods on
`ClientInitializeError`, no downcast, no transport-specific types
leaking into the generic lifecycle layer.

Additional fixes that fall out naturally:

- Response correlation is now checked in `expect_response` for both
  success and error branches. Previously error responses skipped id
  correlation entirely. A new `UncorrelatedErrorResponse` variant
  surfaces responses that cannot be tied to the request.

- When both discover and the legacy fallback fail, a
  `LegacyFallbackFailed` compound error preserves both phases instead
  of discarding the discover error.

Fixes modelcontextprotocol#1040.
@ip2a
ip2a force-pushed the fix/auto-lifecycle-fallback branch from f3885a5 to e6ee5e0 Compare August 9, 2026 03:44
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