Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions crates/rmcp/src/handler/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,16 @@ impl<H: ServerHandler> Service<RoleServer> for H {
.is_some_and(|v| v.as_str() >= ProtocolVersion::V_2026_07_28.as_str());
let requested_version = context.meta.protocol_version();
let uses_inline_negotiation = !matches!(&request, ClientRequest::InitializeRequest(_));
// Legacy-only servers do not implement discovery. MethodNotFound tells
// dual-lifecycle clients to fall back to initialize.
Comment on lines +64 to +65

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!?

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();
Comment on lines +64 to 75

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() {

if !supported_versions.contains(requested_version) {
Expand Down
16 changes: 8 additions & 8 deletions crates/rmcp/tests/test_server_discover_http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ impl ServerHandler for DiscoveryServer {
}

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?

}
}

Expand Down Expand Up @@ -120,7 +120,7 @@ async fn discover_returns_server_metadata_without_session() {
body["result"],
json!({
"resultType": "complete",
"supportedVersions": ["2025-11-25"],
"supportedVersions": ["2025-11-25", "2026-07-28"],
"capabilities": { "tools": {} },
"_meta": {
"io.modelcontextprotocol/serverInfo": {
Expand Down Expand Up @@ -153,16 +153,16 @@ async fn discover_does_not_require_initialization_in_legacy_session_mode() {
async fn discover_rejects_unsupported_version_with_http_400() {
let (client, url, cancellation_token) = spawn_server(true).await;

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

assert_eq!(response.status(), 400);
let body: serde_json::Value = response.json().await.expect("response should be JSON");
assert_eq!(body["error"]["code"], -32022);
assert_eq!(
body["error"]["data"],
json!({
"requested": "2026-07-28",
"supported": ["2025-11-25"]
"requested": "2099-01-01",
"supported": ["2025-11-25", "2026-07-28"]
})
);

Expand Down Expand Up @@ -191,7 +191,7 @@ async fn regular_request_rejects_server_unsupported_meta_version() {
"method": "tools/list",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28"
"io.modelcontextprotocol/protocolVersion": "2099-01-01"
}
}
});
Expand All @@ -200,7 +200,7 @@ async fn regular_request_rejects_server_unsupported_meta_version() {
.post(&url)
.header("Content-Type", "application/json")
.header("Accept", "application/json, text/event-stream")
.header("MCP-Protocol-Version", "2026-07-28")
.header("MCP-Protocol-Version", "2099-01-01")
.header("Mcp-Method", "tools/list")
.json(&body)
.send()
Expand Down Expand Up @@ -365,7 +365,7 @@ async fn discover_accepts_missing_optional_client_info() {
async fn discover_error_uses_http_400_when_sse_is_configured() {
let (client, url, cancellation_token) = spawn_server(false).await;

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

assert_eq!(response.status(), 400);
assert_eq!(
Expand Down
71 changes: 65 additions & 6 deletions crates/rmcp/tests/test_stateless_server_requests.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
#![cfg(all(feature = "server", not(feature = "local")))]

use std::{
sync::{Arc, Mutex},
borrow::Cow,
sync::{
Arc, Mutex,
atomic::{AtomicBool, Ordering},
},
time::Duration,
};

use rmcp::{
ServerHandler, ServiceExt,
ClientLifecycleMode, ClientServiceExt, ServerHandler, ServiceExt,
model::{
ClientCapabilities, ClientJsonRpcMessage, ClientRequest, DiscoverRequest,
DiscoverRequestParams, ErrorCode, ErrorData, Implementation, ListToolsRequest,
ListToolsResult, NumberOrString, PaginatedRequestParams, ProgressNotificationParam,
ProgressToken, ProtocolVersion, RequestId, RequestMetaObject, ServerJsonRpcMessage,
ServerNotification,
DiscoverRequestMethod, DiscoverRequestParams, DiscoverResult, ErrorCode, ErrorData,
Implementation, ListToolsRequest, ListToolsResult, NumberOrString, PaginatedRequestParams,
ProgressNotificationParam, ProgressToken, ProtocolVersion, RequestId, RequestMetaObject,
ServerJsonRpcMessage, ServerNotification,
},
service::{MaybeSendFuture, RequestContext, RoleServer},
transport::{IntoTransport, Transport},
Expand All @@ -23,6 +27,25 @@ struct StatelessServer;

impl ServerHandler for StatelessServer {}

#[derive(Clone)]
struct LegacyServer {
discover_called: Arc<AtomicBool>,
}

impl ServerHandler for LegacyServer {
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
Cow::Borrowed(ProtocolVersion::known_up_to(&ProtocolVersion::V_2025_11_25))
}

async fn discover(
&self,
_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(),
))

}
}

fn complete_meta() -> RequestMetaObject {
complete_meta_for("stateless-client")
}
Expand Down Expand Up @@ -123,6 +146,42 @@ async fn stateless_server_rejects_missing_metadata_on_every_request() {
running.cancel().await.expect("cancel server");
}

#[tokio::test]
async fn legacy_only_server_rejects_discovery_before_dispatch() {
let discover_called = Arc::new(AtomicBool::new(false));
let server = LegacyServer {
discover_called: Arc::clone(&discover_called),
};
let (server_transport, client_transport) = tokio::io::duplex(4096);
let server_task = tokio::spawn(async move {
server
.serve(server_transport)
.await
.expect("server should start")
});

let client = ()
.serve_with_lifecycle(
client_transport,
ClientLifecycleMode::Auto {
preferred_versions: vec![ProtocolVersion::V_2026_07_28],
legacy_version: Some(ProtocolVersion::V_2025_11_25),
},
)
.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
);


client.cancel().await.expect("cancel client");
server_task
.await
.expect("server task")
.cancel()
.await
.expect("cancel server");
}

#[derive(Clone)]
struct ContextServer {
seen_clients: Arc<Mutex<Vec<String>>>,
Expand Down
Loading