Repository navigation
ClientLifecycleMode::Auto does not fall back for deployed legacy-server responses #1040
Description
Summary
ClientLifecycleMode::Auto only retries legacy initialize when
server/discover fails with JSON-RPC METHOD_NOT_FOUND (-32601). Existing
production MCP servers communicate unsupported modern discovery through other
recognizable responses, so enabling modern negotiation breaks servers that
previously worked.
Observed against deployed servers
Testing protocol 2026-07-28 against public legacy MCP services reproduced the
problem with:
- OpenAI documentation, which exposes five tools through legacy initialization.
- Microsoft Learn, which exposes three tools.
- DeepWiki, which exposes three tools.
All three worked before modern discovery was enabled. With the modern lifecycle
enabled, startup failed with:
Unsupported protocol version: 2026-07-28
Codex’s existing rmcp 1.8 implementation connected to the same servers by retrying initialize with protocol 2025-06-18.
Current behavior
match discover_startup(...).await {
Ok(()) => {}
Err(ClientInitializeError::JsonRpcError(error))
if error.code == ErrorCode::METHOD_NOT_FOUND =>
{
legacy_startup(...).await?;
}
Err(error) => return Err(error),
}Production-shaped HTTP prevalidation responses observed during interoperability
testing include:
{
"jsonrpc": "2.0",
"id": null,
"error": {
"code": -32000,
"message": "Bad Request: Unsupported protocol version: 2026-07-28 (supported versions: 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05, 2024-10-07)"
}
}{
"jsonrpc": "2.0",
"id": null,
"error": {
"code": -32000,
"message": "Bad Request: No valid session ID provided"
}
}Both occur as initial HTTP 400 responses before a session exists. Other useful
legacy-only signals include correlated unsupported-version errors, discovery
results advertising exclusively historical protocol versions, and initial HTTP
404/405 responses.
Observed versus expected lifecycle
Actual:
→ server/discover, protocol 2026-07-28
← recognizable legacy-only rejection
✗ startup terminates without attempting initialize
Expected:
→ server/discover, protocol 2026-07-28
← recognizable legacy-only rejection
→ initialize, protocol 2025-06-18
← successful initialization
→ notifications/initialized
→ tools/list
Integration tests now reproduce these production-shaped responses and verify
the successful sequence
["server/discover", "initialize", "notifications/initialized"].
Downgrade safety
Fallback must remain conservative. Unrelated response IDs, arbitrary null-ID
errors, unknown or future protocol versions, HTTP 401/403 responses, existing-
session 404 responses, and ordinary modern-server rejections must not trigger
downgrade.
The observed public-server failures and successful reference-client comparison
demonstrate an existing interoperability regression rather than a hypothetical
extension to automatic lifecycle negotiation.
Activity
fix: conservatively retry legacy server initialization (modelcontextp…
fix: conservatively retry legacy server initialization (modelcontextp…
fix: conservatively retry legacy server initialization (modelcontextp…
chrisbennight commentedon Aug 3, 2026
Just adding two more failure cases I saw -
rmcp 2.2.0 server, default (stateful) streamable-HTTP config - the message parses, but the session layer rejects any sessionless non-
initializerequest before dispatch:https://github.com/modelcontextprotocol/rust-sdk/blob/830e088d733c7964c806a2305760dd8deb30dff9/crates/rmcp/src/transport/common/server_side_http.rs
mcp-go v0.57.0 server, default config - the session manager treats the sessionless probe as an unknown session:
https://github.com/mark3labs/mcp-go/blob/d7c7b06e4d2506f914eb8f53f35dc42feeb2e0f4/server/streamable_http.go
Since these are transport level not sure #1088 will fix them, since it reuses the same transport I think
What I ended up doing was adding retry logic in my client to work around it - dial once under
Auto, and on any handshake failure re-dial once withClientLifecycleMode::Initializeon a fresh transport (the refusal kills the transport worker, so there's nothing left to retry on). I ended up not matching on specific errors since, as noted above, every server phrases the refusal differently - and the status arrives flattened intoUnexpectedServerResponseanyway. Awkward, but it works for me in a gateway where I need to paper over servers I don't control.I'd also point to https://modelcontextprotocol.io/specification/2026-07-28/basic/lifecycle#backward-compatibility-with-initialization-based-versions as a response to
consider from the spec
In both cases, a recognized modern JSON-RPC error (such as [UnsupportedProtocolVersionError](https://modelcontextprotocol.io/specification/2026-07-28/schema#unsupportedprotocolversionerror)) identifies a modern server: the client retries with a supported version rather than falling back. Anything else identifies a legacy server.. - I'd make the argument that downgrade is the conservative option / default for things that aren't a modern JSON-RPC response.(sorry if I misunderstood your point!)
fix(client): classify discover failures before Auto legacy fallback
fix(client): classify discover failures before Auto legacy fallback
fix(client): classify discover outcome at source, not at the error type
fix(client): classify discover outcome at source, not at the error type
fix(client): classify discover outcome at source, not at the error type
fix(client): classify discover outcome at source, not at the error ty…
jamadeo commentedon Aug 11, 2026
Reopening as the linked PR is a good step but doesn't handle everything. Most importantly, in the HTTP transport case, we still need to trigger a fallback on empty or malformed 4xx responses, which requires some transport work.
jamadeo commentedon Aug 25, 2026
@tsarlandie-oai I think this can now be closed with #1133 and #1211 both in. Please re-open if you see a remaining gap we need to cross here.