Skip to content

Commit 61e1d60

Browse files
author
Julien Côté
committed
fix: preserve legacy fallback after rejected discovery
A dual-lifecycle stdio client probes a server with server/discover before deciding whether to use the modern per-request metadata lifecycle or the legacy initialize handshake. rmcp previously marked the connection as requiring per-request metadata before the discovery handler completed. When discovery returned an error identifying a legacy server, the auto client correctly fell back to initialize on the same connection, but the server retained the modern metadata requirement and rejected later legacy requests. Derive discovery support from supported_protocol_versions. Servers that advertise only initialization-based versions return MethodNotFound without dispatching discovery. Keep rejected discovery requests in the pre-initialization loop, and require per-request metadata only after an inline request establishes the modern lifecycle. Add separate regression coverage for legacy-only version configuration and for rejected discovery followed by initialize and a metadata-light tools/list request. Update the HTTP discovery test server to advertise the discovery lifecycle. Validation: - cargo +nightly fmt --all - cargo clippy --all-targets --all-features -- -D warnings - affected client/server lifecycle tests pass - cargo build --all-features - cargo test --all-features is blocked by the unrelated Windows-only transport::auth::tests::default_http_client_preserves_connection_failure_cause assertion Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2437c70d-f526-4b60-8ee4-effca05aadd4
1 parent b037c0f commit 61e1d60

4 files changed

Lines changed: 219 additions & 66 deletions

File tree

‎crates/rmcp/src/handler/server.rs‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use crate::{
77
model::*,
88
service::{
99
MaybeSendFuture, NotificationContext, RequestContext, RoleServer, Service, ServiceRole,
10-
SubscriptionContext, negotiate_protocol_version, uses_legacy_lifecycle,
10+
SubscriptionContext, is_legacy_version, negotiate_protocol_version, uses_legacy_lifecycle,
1111
},
1212
};
1313

@@ -61,6 +61,16 @@ impl<H: ServerHandler> Service<RoleServer> for H {
6161
.is_some_and(|v| v.as_str() >= ProtocolVersion::V_2026_07_28.as_str());
6262
let requested_version = context.meta.protocol_version();
6363
let uses_inline_negotiation = !matches!(&request, ClientRequest::InitializeRequest(_));
64+
// Legacy-only servers do not implement discovery. MethodNotFound tells
65+
// dual-lifecycle clients to fall back to initialize.
66+
if matches!(&request, ClientRequest::DiscoverRequest(_))
67+
&& self
68+
.supported_protocol_versions()
69+
.iter()
70+
.all(is_legacy_version)
71+
{
72+
return Err(McpError::method_not_found::<DiscoverRequestMethod>());
73+
}
6474
if uses_inline_negotiation && let Some(requested_version) = requested_version.as_ref() {
6575
let supported_versions = self.supported_protocol_versions();
6676
if !supported_versions.contains(requested_version) {

‎crates/rmcp/src/service/server.rs‎

Lines changed: 88 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -564,70 +564,107 @@ where
564564

565565
// Get initialize request; the MCP spec permits ping before initialize.
566566
// See: https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
567-
let (request, id) = loop {
567+
let (initialize_request, id) = loop {
568568
let msg = expect_next_message(&mut transport, "initialize request").await?;
569-
match msg {
570-
ClientJsonRpcMessage::Request(req)
571-
if matches!(req.request, ClientRequest::PingRequest(_)) =>
572-
{
573-
transport
574-
.send(ServerJsonRpcMessage::response(
575-
ServerResult::EmptyResult(EmptyResult {}),
576-
req.id,
577-
))
578-
.await
579-
.map_err(|error| {
580-
ServerInitializeError::transport::<T>(
581-
error,
582-
"sending pre-init ping response",
583-
)
584-
})?;
585-
}
586-
ClientJsonRpcMessage::Request(req) => break (req.request, req.id),
569+
let request = match msg {
570+
ClientJsonRpcMessage::Request(request) => request,
587571
other => {
588572
return Err(ServerInitializeError::ExpectedInitializeRequest(Some(
589573
other,
590574
)));
591575
}
576+
};
577+
578+
if matches!(&request.request, ClientRequest::PingRequest(_)) {
579+
transport
580+
.send(ServerJsonRpcMessage::response(
581+
ServerResult::EmptyResult(EmptyResult {}),
582+
request.id,
583+
))
584+
.await
585+
.map_err(|error| {
586+
ServerInitializeError::transport::<T>(error, "sending pre-init ping response")
587+
})?;
588+
continue;
592589
}
593-
};
594590

595-
let initialize_request = match request {
596-
ClientRequest::InitializeRequest(request) => request,
597-
request => {
598-
let missing_metadata = request
599-
.get_meta()
600-
.missing_required_keys(&ProtocolVersion::V_2026_07_28);
601-
if !missing_metadata.is_empty() {
602-
transport
603-
.send(ServerJsonRpcMessage::error(
604-
missing_request_metadata_error(&missing_metadata),
605-
Some(id.clone()),
606-
))
607-
.await
608-
.map_err(|error| {
591+
let id = request.id;
592+
match request.request {
593+
ClientRequest::InitializeRequest(request) => break (request, id),
594+
mut request => {
595+
let missing_metadata = request
596+
.get_meta()
597+
.missing_required_keys(&ProtocolVersion::V_2026_07_28);
598+
if !missing_metadata.is_empty() {
599+
transport
600+
.send(ServerJsonRpcMessage::error(
601+
missing_request_metadata_error(&missing_metadata),
602+
Some(id.clone()),
603+
))
604+
.await
605+
.map_err(|error| {
606+
ServerInitializeError::transport::<T>(
607+
error,
608+
"sending pre-init metadata error response",
609+
)
610+
})?;
611+
return Err(ServerInitializeError::ExpectedInitializeRequest(Some(
612+
ClientJsonRpcMessage::request(request, id),
613+
)));
614+
}
615+
616+
let (peer, peer_rx) = Peer::new(id_provider.clone(), None);
617+
if matches!(&request, ClientRequest::DiscoverRequest(_)) {
618+
let context = RequestContext {
619+
ct: ct.child_token(),
620+
id: id.clone(),
621+
meta: std::mem::take(request.get_meta_mut()),
622+
extensions: std::mem::take(request.extensions_mut()),
623+
peer: peer.clone(),
624+
};
625+
let response = match service.handle_request(request, context).await {
626+
Ok(result) => ServerJsonRpcMessage::response(result, id),
627+
Err(error) => {
628+
transport
629+
.send(ServerJsonRpcMessage::error(error, Some(id)))
630+
.await
631+
.map_err(|error| {
632+
ServerInitializeError::transport::<T>(
633+
error,
634+
"sending rejected discover response",
635+
)
636+
})?;
637+
continue;
638+
}
639+
};
640+
641+
peer.require_request_metadata();
642+
transport.send(response).await.map_err(|error| {
609643
ServerInitializeError::transport::<T>(
610644
error,
611-
"sending pre-init metadata error response",
645+
"sending negotiated request response",
612646
)
613647
})?;
614-
return Err(ServerInitializeError::ExpectedInitializeRequest(Some(
615-
ClientJsonRpcMessage::request(request, id),
616-
)));
648+
return Ok(serve_inner(
649+
service,
650+
transport,
651+
peer,
652+
peer_rx,
653+
VecDeque::new(),
654+
ct,
655+
));
656+
}
657+
658+
peer.require_request_metadata();
659+
return Ok(serve_inner(
660+
service,
661+
transport,
662+
peer,
663+
peer_rx,
664+
VecDeque::from([ClientJsonRpcMessage::request(request, id)]),
665+
ct,
666+
));
617667
}
618-
let (peer, peer_rx) = Peer::new(id_provider, None);
619-
peer.require_request_metadata();
620-
// Dispatch the request from inside the service loop rather than
621-
// inline: its handler may send notifications through `peer`, which
622-
// only complete once the loop drains `peer_rx`.
623-
return Ok(serve_inner(
624-
service,
625-
transport,
626-
peer,
627-
peer_rx,
628-
VecDeque::from([ClientJsonRpcMessage::request(request, id)]),
629-
ct,
630-
));
631668
}
632669
};
633670
let requested_protocol_version = initialize_request.params.protocol_version.clone();

‎crates/rmcp/tests/test_server_discover_http.rs‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ impl ServerHandler for DiscoveryServer {
2727
}
2828

2929
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
30-
Cow::Borrowed(&[ProtocolVersion::V_2025_11_25])
30+
Cow::Borrowed(&[ProtocolVersion::V_2025_11_25, ProtocolVersion::V_2026_07_28])
3131
}
3232
}
3333

@@ -120,7 +120,7 @@ async fn discover_returns_server_metadata_without_session() {
120120
body["result"],
121121
json!({
122122
"resultType": "complete",
123-
"supportedVersions": ["2025-11-25"],
123+
"supportedVersions": ["2025-11-25", "2026-07-28"],
124124
"capabilities": { "tools": {} },
125125
"_meta": {
126126
"io.modelcontextprotocol/serverInfo": {
@@ -153,16 +153,16 @@ async fn discover_does_not_require_initialization_in_legacy_session_mode() {
153153
async fn discover_rejects_unsupported_version_with_http_400() {
154154
let (client, url, cancellation_token) = spawn_server(true).await;
155155

156-
let response = post_discover(&client, &url, "2026-07-28", Some("2026-07-28")).await;
156+
let response = post_discover(&client, &url, "2099-01-01", Some("2099-01-01")).await;
157157

158158
assert_eq!(response.status(), 400);
159159
let body: serde_json::Value = response.json().await.expect("response should be JSON");
160160
assert_eq!(body["error"]["code"], -32022);
161161
assert_eq!(
162162
body["error"]["data"],
163163
json!({
164-
"requested": "2026-07-28",
165-
"supported": ["2025-11-25"]
164+
"requested": "2099-01-01",
165+
"supported": ["2025-11-25", "2026-07-28"]
166166
})
167167
);
168168

@@ -191,7 +191,7 @@ async fn regular_request_rejects_server_unsupported_meta_version() {
191191
"method": "tools/list",
192192
"params": {
193193
"_meta": {
194-
"io.modelcontextprotocol/protocolVersion": "2026-07-28"
194+
"io.modelcontextprotocol/protocolVersion": "2099-01-01"
195195
}
196196
}
197197
});
@@ -200,7 +200,7 @@ async fn regular_request_rejects_server_unsupported_meta_version() {
200200
.post(&url)
201201
.header("Content-Type", "application/json")
202202
.header("Accept", "application/json, text/event-stream")
203-
.header("MCP-Protocol-Version", "2026-07-28")
203+
.header("MCP-Protocol-Version", "2099-01-01")
204204
.header("Mcp-Method", "tools/list")
205205
.json(&body)
206206
.send()
@@ -365,7 +365,7 @@ async fn discover_accepts_missing_optional_client_info() {
365365
async fn discover_error_uses_http_400_when_sse_is_configured() {
366366
let (client, url, cancellation_token) = spawn_server(false).await;
367367

368-
let response = post_discover(&client, &url, "2026-07-28", Some("2026-07-28")).await;
368+
let response = post_discover(&client, &url, "2099-01-01", Some("2099-01-01")).await;
369369

370370
assert_eq!(response.status(), 400);
371371
assert_eq!(

‎crates/rmcp/tests/test_stateless_server_requests.rs‎

Lines changed: 112 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
11
#![cfg(all(feature = "server", not(feature = "local")))]
22

33
use std::{
4-
sync::{Arc, Mutex},
4+
borrow::Cow,
5+
sync::{
6+
Arc, Mutex,
7+
atomic::{AtomicBool, Ordering},
8+
},
59
time::Duration,
610
};
711

812
use rmcp::{
9-
ServerHandler, ServiceExt,
13+
ClientLifecycleMode, ClientServiceExt, ServerHandler, ServiceExt,
1014
model::{
1115
ClientCapabilities, ClientJsonRpcMessage, ClientRequest, DiscoverRequest,
12-
DiscoverRequestParams, ErrorCode, ErrorData, Implementation, ListToolsRequest,
13-
ListToolsResult, NumberOrString, PaginatedRequestParams, ProgressNotificationParam,
14-
ProgressToken, ProtocolVersion, RequestId, RequestMetaObject, ServerJsonRpcMessage,
15-
ServerNotification,
16+
DiscoverRequestMethod, DiscoverRequestParams, DiscoverResult, ErrorCode, ErrorData,
17+
Implementation, ListToolsRequest, ListToolsResult, NumberOrString, PaginatedRequestParams,
18+
ProgressNotificationParam, ProgressToken, ProtocolVersion, RequestId, RequestMetaObject,
19+
ServerJsonRpcMessage, ServerNotification,
1620
},
1721
service::{MaybeSendFuture, RequestContext, RoleServer, ServerInitializeError},
1822
transport::{IntoTransport, Transport},
@@ -23,6 +27,37 @@ struct StatelessServer;
2327

2428
impl ServerHandler for StatelessServer {}
2529

30+
#[derive(Clone)]
31+
struct LegacyServer {
32+
discover_called: Arc<AtomicBool>,
33+
}
34+
35+
impl ServerHandler for LegacyServer {
36+
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
37+
Cow::Borrowed(ProtocolVersion::known_up_to(&ProtocolVersion::V_2025_11_25))
38+
}
39+
40+
async fn discover(
41+
&self,
42+
_context: RequestContext<RoleServer>,
43+
) -> Result<DiscoverResult, ErrorData> {
44+
self.discover_called.store(true, Ordering::Relaxed);
45+
Err(ErrorData::method_not_found::<DiscoverRequestMethod>())
46+
}
47+
}
48+
49+
#[derive(Clone, Default)]
50+
struct RejectingDiscoveryServer;
51+
52+
impl ServerHandler for RejectingDiscoveryServer {
53+
async fn discover(
54+
&self,
55+
_context: RequestContext<RoleServer>,
56+
) -> Result<DiscoverResult, ErrorData> {
57+
Err(ErrorData::method_not_found::<DiscoverRequestMethod>())
58+
}
59+
}
60+
2661
fn complete_meta() -> RequestMetaObject {
2762
complete_meta_for("stateless-client")
2863
}
@@ -97,6 +132,77 @@ async fn stateless_server_rejects_missing_metadata_on_every_request() {
97132
.expect("cancel server");
98133
}
99134

135+
#[tokio::test]
136+
async fn legacy_only_server_rejects_discovery_before_dispatch() {
137+
let discover_called = Arc::new(AtomicBool::new(false));
138+
let server = LegacyServer {
139+
discover_called: Arc::clone(&discover_called),
140+
};
141+
let (server_transport, client_transport) = tokio::io::duplex(4096);
142+
let server_task = tokio::spawn(async move {
143+
server
144+
.serve(server_transport)
145+
.await
146+
.expect("server should start")
147+
});
148+
149+
let client = ()
150+
.serve_with_lifecycle(
151+
client_transport,
152+
ClientLifecycleMode::Auto {
153+
preferred_versions: vec![ProtocolVersion::V_2026_07_28],
154+
legacy_version: Some(ProtocolVersion::V_2025_11_25),
155+
},
156+
)
157+
.await
158+
.expect("client should fall back to initialize");
159+
160+
assert!(!discover_called.load(Ordering::Relaxed));
161+
162+
client.cancel().await.expect("cancel client");
163+
server_task
164+
.await
165+
.expect("server task")
166+
.cancel()
167+
.await
168+
.expect("cancel server");
169+
}
170+
171+
#[tokio::test]
172+
async fn server_allows_legacy_fallback_after_rejected_discovery() {
173+
let (server_transport, client_transport) = tokio::io::duplex(4096);
174+
let server_task = tokio::spawn(async move {
175+
RejectingDiscoveryServer
176+
.serve(server_transport)
177+
.await
178+
.expect("server should start")
179+
});
180+
181+
let client = ()
182+
.serve_with_lifecycle(
183+
client_transport,
184+
ClientLifecycleMode::Auto {
185+
preferred_versions: vec![ProtocolVersion::V_2026_07_28],
186+
legacy_version: Some(ProtocolVersion::V_2025_11_25),
187+
},
188+
)
189+
.await
190+
.expect("client should fall back to initialize");
191+
192+
client
193+
.list_all_tools()
194+
.await
195+
.expect("legacy tools/list should not require per-request metadata");
196+
197+
client.cancel().await.expect("cancel client");
198+
server_task
199+
.await
200+
.expect("server task")
201+
.cancel()
202+
.await
203+
.expect("cancel server");
204+
}
205+
100206
#[derive(Clone)]
101207
struct ContextServer {
102208
seen_clients: Arc<Mutex<Vec<String>>>,

0 commit comments

Comments
 (0)