Skip to content

fix: reject discovery for legacy-only servers - #1269

Open
JulienCote wants to merge 1 commit into
modelcontextprotocol:mainfrom
JulienCote:fix/legacy-discovery-fallback
Open

JulienCote wants to merge 1 commit into
modelcontextprotocol:mainfrom
JulienCote:fix/legacy-discovery-fallback

Conversation

@JulienCote

@JulienCote JulienCote commented Sep 14, 2026 •

Copy link
Copy Markdown

Summary

  • reject server/discover before handler dispatch when every advertised protocol version uses the classic initialize lifecycle
  • let Auto clients recognize legacy-only servers and fall back to initialize on the same connection
  • keep discovery HTTP fixtures explicitly discovery-capable

Motivation

The default discovery handler advertises supported_protocol_versions. For a server that advertises only initialize-era versions, a modern Auto client otherwise receives a successful discovery response with no compatible discovery version and fails with NoCompatibleProtocolVersion instead of falling back.

Returning MethodNotFound identifies the server as legacy without invoking a custom discovery handler. The bootstrap-neutral handling merged in #1248 then permits the client to continue with initialize safely.

Testing

  • cargo fmt --all -- --check
  • cargo test -p rmcp --features "client reqwest transport-io transport-streamable-http-server" --test test_stateless_server_requests --test test_cancelled_response --test test_server_discover_http

@JulienCote
JulienCote requested a review from a team as a code owner September 14, 2026 16:44
@github-actions github-actions Bot added T-test Testing related changes T-core Core library changes T-handler Handler implementation changes T-service Service layer changes labels Sep 14, 2026
@JulienCote
JulienCote force-pushed the fix/legacy-discovery-fallback branch from 60b94ea to 61e1d60 Compare September 14, 2026 21:41
extensions: std::mem::take(request.extensions_mut()),
peer: peer.clone(),
};
let response = match service.handle_request(request, context).await {

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 serve_server_with_ct_inner, which manages the connection lifecycle, the discovery handler now waits forever on context.peer.notify_progress(...). Sending the notification waits for an acknowledgment from the service loop, but serve_inner does not start until the handler returns.

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.

Addressed by rebasing onto current main. #1248 now handles bootstrap discovery with an isolated peer, so a handler notification cannot wait on the not-yet-started service loop. This PR no longer changes that lifecycle path and is focused only on rejecting discovery for legacy-only servers.

zchee added a commit to zchee/mcp-google-service that referenced this pull request Sep 26, 2026
Claude Code probes a stdio server with `server/discover` before
`initialize` and falls back to `initialize` when the probe is refused.
This server refuses it (2026-07-28 is deliberately not offered, see
542c2e1), but rmcp 3.1.3 then marks the session as needing per-request
`_meta` and never clears the mark, so the legacy `tools/list` that
follows is refused with -32602 and the client sees no tools at all.

Wrap the stdio transport in a gate that answers the probe itself with
-32601 Method not found, which is what a legacy-only server would say
and what makes the client fall back; rmcp never sees the probe and
starts from `initialize` as before. The gate turns itself off once the
session has opened and whenever a 2026-07-28-or-newer revision is
offered, so it is inert on every path except the one it exists for.

This is a workaround for a defect rmcp fixes upstream in
modelcontextprotocol/rust-sdk#1269. A canary test pins the defect
against bare rmcp; when it fails after a dependency bump, the gate is
to be deleted and `stdio()` handed to `serve` directly again.

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

DaleSeo commented Oct 5, 2026

Copy link
Copy Markdown
Member

This has merge conflicts with main now. Could you rebase onto the latest main and resolve them? Thanks!

@JulienCote
JulienCote force-pushed the fix/legacy-discovery-fallback branch from 10d27a2 to 1b6cbb0 Compare October 5, 2026 21:11
@JulienCote JulienCote changed the title fix: preserve legacy fallback after rejected discovery fix: reject discovery for legacy-only servers Oct 5, 2026
@JulienCote

Copy link
Copy Markdown
Author

Rebased onto the latest main and resolved the conflicts. I also narrowed the PR to one commit covering only the remaining legacy-only case; the broader bootstrap/fallback behavior is now provided by #1248.

@github-actions github-actions Bot removed the T-service Service layer changes label Oct 5, 2026
Servers that advertise only initialize-based protocol versions cannot
establish the discovery lifecycle. Return MethodNotFound before dispatching
`server/discover` so Auto clients fall back to `initialize` without invoking
the discovery handler.

Update discovery HTTP fixtures to advertise a discovery-capable version and
cover the legacy-only fallback path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@JulienCote
JulienCote force-pushed the fix/legacy-discovery-fallback branch from 1b6cbb0 to 13e4798 Compare October 5, 2026 21:59

fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
Cow::Borrowed(&[ProtocolVersion::V_2025_11_25])
Cow::Borrowed(&[ProtocolVersion::V_2025_11_25, ProtocolVersion::V_2026_07_28])

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.

With streamable HTTP, MethodNotFound becomes an HTTP 404. The client fallback then depends on streamable_http_client converting that 404 back into a JSON-RPC error. Now that this fixture supports discovery and the new test only covers the duplex transport, we don’t have anything that tests the full HTTP path. Would it be worth adding an HTTP test for a legacy-only server to check the 404 status and the -32601 response body, or the Auto fallback?

_context: RequestContext<RoleServer>,
) -> Result<DiscoverResult, ErrorData> {
self.discover_called.store(true, Ordering::Relaxed);
Err(ErrorData::method_not_found::<DiscoverRequestMethod>())

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.

Since this stub also returns MethodNotFound, the client would still fall back if we removed the new guard. That means only the discover_called flag would catch the regression.

Suggested change
Err(ErrorData::method_not_found::<DiscoverRequestMethod>())
Ok(DiscoverResult::from_server_info(
self.supported_protocol_versions().into_owned(),
self.get_info(),
))

Comment on lines +64 to +65
// Legacy-only servers do not implement discovery. MethodNotFound tells
// dual-lifecycle clients to fall back to initialize.

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.

A custom ServerHandler::discover override is no longer called when supported_protocol_versions returns only versions from the initialize era. Implementers can't tell that from the trait, so could we add a short note to the discover doc comment in server_handler_methods!?

.await
.expect("client should fall back to initialize");

assert!(!discover_called.load(Ordering::Relaxed));

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.

This confirms the client started, but not that it actually used the legacy_version it was given. One more assertion would verify that.

Suggested change
assert!(!discover_called.load(Ordering::Relaxed));
assert!(!discover_called.load(Ordering::Relaxed));
assert_eq!(
client.peer_info().expect("peer info").protocol_version,
ProtocolVersion::V_2025_11_25
);

Comment on lines +64 to 75
// Legacy-only servers do not implement discovery. MethodNotFound tells
// dual-lifecycle clients to fall back to initialize.
if matches!(&request, ClientRequest::DiscoverRequest(_))
&& self
.supported_protocol_versions()
.iter()
.all(ProtocolVersion::has_initialize)
{
return Err(McpError::method_not_found::<DiscoverRequestMethod>());
}
if uses_inline_negotiation && let Some(requested_version) = requested_version.as_ref() {
let supported_versions = self.supported_protocol_versions();

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.

The guard and the version check below both call supported_protocol_versions(), so a custom implementation that returns Cow::Owned allocates twice per request. Reading it once lets both checks use the same list.

Suggested change
// Legacy-only servers do not implement discovery. MethodNotFound tells
// dual-lifecycle clients to fall back to initialize.
if matches!(&request, ClientRequest::DiscoverRequest(_))
&& self
.supported_protocol_versions()
.iter()
.all(ProtocolVersion::has_initialize)
{
return Err(McpError::method_not_found::<DiscoverRequestMethod>());
}
if uses_inline_negotiation && let Some(requested_version) = requested_version.as_ref() {
let supported_versions = self.supported_protocol_versions();
let supported_versions = self.supported_protocol_versions();
// Legacy-only servers do not implement discovery. MethodNotFound tells
// dual-lifecycle clients to fall back to initialize.
if matches!(&request, ClientRequest::DiscoverRequest(_))
&& supported_versions
.iter()
.all(ProtocolVersion::has_initialize)
{
return Err(McpError::method_not_found::<DiscoverRequestMethod>());
}
if uses_inline_negotiation && let Some(requested_version) = requested_version.as_ref() {

This branch has not been deployed

No deployments
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-handler Handler implementation changes T-test Testing related changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants