From ceffe0637ae556e5566a6cd76d8710a127f9e087 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Fri, 5 Jun 2026 20:34:00 +1000 Subject: [PATCH 1/3] feat: implement SEP-2164 resource not found errors --- crates/rmcp/src/model.rs | 42 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 4aabab1d0..0b332c198 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -152,6 +152,7 @@ impl std::fmt::Display for ProtocolVersion { } impl ProtocolVersion { + pub const V_2026_07_28: Self = Self(Cow::Borrowed("2026-07-28")); pub const V_2025_11_25: Self = Self(Cow::Borrowed("2025-11-25")); pub const V_2025_06_18: Self = Self(Cow::Borrowed("2025-06-18")); pub const V_2025_03_26: Self = Self(Cow::Borrowed("2025-03-26")); @@ -164,6 +165,7 @@ impl ProtocolVersion { Self::V_2025_03_26, Self::V_2025_06_18, Self::V_2025_11_25, + Self::V_2026_07_28, ]; /// Returns the string representation of this protocol version. @@ -193,6 +195,7 @@ impl<'de> Deserialize<'de> for ProtocolVersion { "2025-03-26" => return Ok(ProtocolVersion::V_2025_03_26), "2025-06-18" => return Ok(ProtocolVersion::V_2025_06_18), "2025-11-25" => return Ok(ProtocolVersion::V_2025_11_25), + "2026-07-28" => return Ok(ProtocolVersion::V_2026_07_28), _ => {} } Ok(ProtocolVersion(Cow::Owned(s))) @@ -544,6 +547,25 @@ impl ErrorData { pub fn resource_not_found(message: impl Into>, data: Option) -> Self { Self::new(ErrorCode::RESOURCE_NOT_FOUND, message, data) } + + /// Create a resource-not-found error using the code required by the negotiated protocol version. + /// + /// SEP-2164 standardizes resource-not-found as JSON-RPC `INVALID_PARAMS` (`-32602`) + /// starting with protocol version `2026-07-28`. Older protocol versions continue to use + /// the legacy MCP-specific `RESOURCE_NOT_FOUND` code (`-32002`). + pub fn resource_not_found_for( + protocol_version: &ProtocolVersion, + message: impl Into>, + data: Option, + ) -> Self { + let code = if protocol_version.as_str() >= ProtocolVersion::V_2026_07_28.as_str() { + ErrorCode::INVALID_PARAMS + } else { + ErrorCode::RESOURCE_NOT_FOUND + }; + Self::new(code, message, data) + } + pub fn parse_error(message: impl Into>, data: Option) -> Self { Self::new(ErrorCode::PARSE_ERROR, message, data) } @@ -4021,6 +4043,26 @@ mod tests { assert_eq!(json_url, expected_url_json); } + #[test] + fn resource_not_found_for_uses_legacy_code_for_older_protocol_versions() { + let error = ErrorData::resource_not_found_for( + &ProtocolVersion::V_2025_11_25, + "resource not found", + None, + ); + assert_eq!(error.code, ErrorCode::RESOURCE_NOT_FOUND); + } + + #[test] + fn resource_not_found_for_uses_invalid_params_for_sep_2164_protocol_versions() { + let error = ErrorData::resource_not_found_for( + &ProtocolVersion::V_2026_07_28, + "resource not found", + None, + ); + assert_eq!(error.code, ErrorCode::INVALID_PARAMS); + } + #[test] fn notification_without_params_should_deserialize_as_bare_jsonrpc_message() { let payload = b"{\"method\":\"notifications/initialized\",\"jsonrpc\":\"2.0\"}"; From ebaaa46819c400881bb71176b50b12156f737838 Mon Sep 17 00:00:00 2001 From: Michael Neale Date: Sun, 7 Jun 2026 17:22:57 +1000 Subject: [PATCH 2/3] test: update protocol version utility expectations --- crates/rmcp/tests/test_custom_headers.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/rmcp/tests/test_custom_headers.rs b/crates/rmcp/tests/test_custom_headers.rs index 9b9dfc058..736dce18e 100644 --- a/crates/rmcp/tests/test_custom_headers.rs +++ b/crates/rmcp/tests/test_custom_headers.rs @@ -866,16 +866,18 @@ async fn test_server_rejects_unsupported_protocol_version() { fn test_protocol_version_utilities() { use rmcp::model::ProtocolVersion; + assert_eq!(ProtocolVersion::V_2026_07_28.as_str(), "2026-07-28"); assert_eq!(ProtocolVersion::V_2025_11_25.as_str(), "2025-11-25"); assert_eq!(ProtocolVersion::V_2025_06_18.as_str(), "2025-06-18"); assert_eq!(ProtocolVersion::V_2025_03_26.as_str(), "2025-03-26"); assert_eq!(ProtocolVersion::V_2024_11_05.as_str(), "2024-11-05"); - assert_eq!(ProtocolVersion::KNOWN_VERSIONS.len(), 4); + assert_eq!(ProtocolVersion::KNOWN_VERSIONS.len(), 5); assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2024_11_05)); assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_03_26)); assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_06_18)); assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_11_25)); + assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2026_07_28)); } /// Integration test: Verify server validates only the Host header for DNS rebinding protection From 8a2683755855fc760fa04f40633ea203191d1c81 Mon Sep 17 00:00:00 2001 From: Dale Seo <5466341+DaleSeo@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:32:26 -0400 Subject: [PATCH 3/3] feat: gate not-found code at server boundary --- conformance/src/bin/server.rs | 2 +- crates/rmcp/src/handler/server.rs | 17 +++- crates/rmcp/src/model.rs | 40 +------- crates/rmcp/src/service.rs | 10 ++ .../tests/test_resource_not_found_version.rs | 91 +++++++++++++++++++ 5 files changed, 119 insertions(+), 41 deletions(-) create mode 100644 crates/rmcp/tests/test_resource_not_found_version.rs diff --git a/conformance/src/bin/server.rs b/conformance/src/bin/server.rs index 28a9f1d91..c3424f612 100644 --- a/conformance/src/bin/server.rs +++ b/conformance/src/bin/server.rs @@ -615,7 +615,7 @@ impl ServerHandler for ConformanceServer { } else { Err(ErrorData::resource_not_found( format!("Resource not found: {}", uri), - None, + Some(json!({ "uri": uri })), )) } } diff --git a/crates/rmcp/src/handler/server.rs b/crates/rmcp/src/handler/server.rs index 8673a8bfd..cf6a80324 100644 --- a/crates/rmcp/src/handler/server.rs +++ b/crates/rmcp/src/handler/server.rs @@ -22,7 +22,9 @@ impl Service for H { request: ::PeerReq, context: RequestContext, ) -> Result<::Resp, McpError> { - match request { + // `context` is moved into the dispatch below, so read the negotiated version first. + let protocol_version = context.protocol_version(); + let result = match request { ClientRequest::InitializeRequest(request) => self .initialize(request.params, context) .await @@ -127,7 +129,18 @@ impl Service for H { .cancel_task(request.params, context) .await .map(ServerResult::CancelTaskResult), - } + }; + // SEP-2164: peers negotiating 2026-07-28+ get the standard INVALID_PARAMS code for + // resource-not-found; older peers keep RESOURCE_NOT_FOUND. ISO `YYYY-MM-DD` versions + // compare lexically the same as chronologically. + let use_invalid_params = + protocol_version.is_some_and(|v| v.as_str() >= ProtocolVersion::V_2026_07_28.as_str()); + result.map_err(|mut error| { + if use_invalid_params && error.code == ErrorCode::RESOURCE_NOT_FOUND { + error.code = ErrorCode::INVALID_PARAMS; + } + error + }) } async fn handle_notification( diff --git a/crates/rmcp/src/model.rs b/crates/rmcp/src/model.rs index 0b332c198..7ade8435f 100644 --- a/crates/rmcp/src/model.rs +++ b/crates/rmcp/src/model.rs @@ -544,28 +544,12 @@ impl ErrorData { data, } } + /// Resource-not-found error (`-32002`). The server upgrades this to `INVALID_PARAMS` + /// (`-32602`) for peers negotiating protocol `2026-07-28` or newer (SEP-2164). pub fn resource_not_found(message: impl Into>, data: Option) -> Self { Self::new(ErrorCode::RESOURCE_NOT_FOUND, message, data) } - /// Create a resource-not-found error using the code required by the negotiated protocol version. - /// - /// SEP-2164 standardizes resource-not-found as JSON-RPC `INVALID_PARAMS` (`-32602`) - /// starting with protocol version `2026-07-28`. Older protocol versions continue to use - /// the legacy MCP-specific `RESOURCE_NOT_FOUND` code (`-32002`). - pub fn resource_not_found_for( - protocol_version: &ProtocolVersion, - message: impl Into>, - data: Option, - ) -> Self { - let code = if protocol_version.as_str() >= ProtocolVersion::V_2026_07_28.as_str() { - ErrorCode::INVALID_PARAMS - } else { - ErrorCode::RESOURCE_NOT_FOUND - }; - Self::new(code, message, data) - } - pub fn parse_error(message: impl Into>, data: Option) -> Self { Self::new(ErrorCode::PARSE_ERROR, message, data) } @@ -4043,26 +4027,6 @@ mod tests { assert_eq!(json_url, expected_url_json); } - #[test] - fn resource_not_found_for_uses_legacy_code_for_older_protocol_versions() { - let error = ErrorData::resource_not_found_for( - &ProtocolVersion::V_2025_11_25, - "resource not found", - None, - ); - assert_eq!(error.code, ErrorCode::RESOURCE_NOT_FOUND); - } - - #[test] - fn resource_not_found_for_uses_invalid_params_for_sep_2164_protocol_versions() { - let error = ErrorData::resource_not_found_for( - &ProtocolVersion::V_2026_07_28, - "resource not found", - None, - ); - assert_eq!(error.code, ErrorCode::INVALID_PARAMS); - } - #[test] fn notification_without_params_should_deserialize_as_bare_jsonrpc_message() { let payload = b"{\"method\":\"notifications/initialized\",\"jsonrpc\":\"2.0\"}"; diff --git a/crates/rmcp/src/service.rs b/crates/rmcp/src/service.rs index d938cd660..7c9de7cd3 100644 --- a/crates/rmcp/src/service.rs +++ b/crates/rmcp/src/service.rs @@ -674,6 +674,16 @@ impl RequestContext { } } +#[cfg(feature = "server")] +impl RequestContext { + /// The protocol version the client negotiated, or `None` before peer info is recorded. + pub fn protocol_version(&self) -> Option { + self.peer + .peer_info() + .map(|info| info.protocol_version.clone()) + } +} + /// Request execution context #[derive(Debug, Clone)] #[non_exhaustive] diff --git a/crates/rmcp/tests/test_resource_not_found_version.rs b/crates/rmcp/tests/test_resource_not_found_version.rs new file mode 100644 index 000000000..255accb8c --- /dev/null +++ b/crates/rmcp/tests/test_resource_not_found_version.rs @@ -0,0 +1,91 @@ +//! SEP-2164: the resource-not-found error code follows the negotiated protocol version. +//! +//! `2026-07-28` and newer get the standard `INVALID_PARAMS` (-32602); older versions +//! keep the legacy `RESOURCE_NOT_FOUND` (-32002). +#![cfg(not(feature = "local"))] +#![cfg(feature = "client")] + +use rmcp::{ + ClientHandler, RoleServer, ServerHandler, ServiceError, ServiceExt, + model::{ + ClientInfo, ErrorCode, ErrorData, ProtocolVersion, ReadResourceRequestParams, + ReadResourceResult, + }, + service::RequestContext, +}; + +#[derive(Debug, Clone, Default)] +struct ResourceServer; + +impl ServerHandler for ResourceServer { + async fn read_resource( + &self, + _request: ReadResourceRequestParams, + _context: RequestContext, + ) -> Result { + Err(ErrorData::resource_not_found("resource not found", None)) + } +} + +#[derive(Debug, Clone)] +struct VersionedClient { + protocol_version: ProtocolVersion, +} + +impl ClientHandler for VersionedClient { + fn get_info(&self) -> ClientInfo { + let mut info = ClientInfo::default(); + info.protocol_version = self.protocol_version.clone(); + info + } +} + +async fn not_found_code(client_version: ProtocolVersion) -> ErrorCode { + let (server_transport, client_transport) = tokio::io::duplex(4096); + + let server_handle = tokio::spawn(async move { + ResourceServer + .serve(server_transport) + .await? + .waiting() + .await?; + anyhow::Ok(()) + }); + + let client = VersionedClient { + protocol_version: client_version, + } + .serve(client_transport) + .await + .expect("client should connect"); + + let error = client + .read_resource(ReadResourceRequestParams::new("missing://resource")) + .await + .expect_err("missing resource should error"); + + let code = match error { + ServiceError::McpError(data) => data.code, + other => panic!("expected McpError, got: {other:?}"), + }; + + client.cancel().await.expect("client should cancel"); + server_handle.await.expect("server task").expect("server"); + code +} + +#[tokio::test] +async fn legacy_version_gets_resource_not_found_code() { + assert_eq!( + not_found_code(ProtocolVersion::V_2025_11_25).await, + ErrorCode::RESOURCE_NOT_FOUND, + ); +} + +#[tokio::test] +async fn sep_2164_version_gets_invalid_params_code() { + assert_eq!( + not_found_code(ProtocolVersion::V_2026_07_28).await, + ErrorCode::INVALID_PARAMS, + ); +}