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 4aabab1d0..7ade8435f 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))) @@ -541,9 +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) } + pub fn parse_error(message: impl Into>, data: Option) -> Self { Self::new(ErrorCode::PARSE_ERROR, message, data) } 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_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 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, + ); +}