feat: standardize resource-not-found error code (SEP-2164) (#899)
* feat: implement SEP-2164 resource not found errors * test: update protocol version utility expectations * feat: gate not-found code at server boundary --------- Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
parent
8f5310b424
commit
95a8e961e0
6 changed files with 126 additions and 4 deletions
|
|
@ -615,7 +615,7 @@ impl ServerHandler for ConformanceServer {
|
||||||
} else {
|
} else {
|
||||||
Err(ErrorData::resource_not_found(
|
Err(ErrorData::resource_not_found(
|
||||||
format!("Resource not found: {}", uri),
|
format!("Resource not found: {}", uri),
|
||||||
None,
|
Some(json!({ "uri": uri })),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,9 @@ impl<H: ServerHandler> Service<RoleServer> for H {
|
||||||
request: <RoleServer as ServiceRole>::PeerReq,
|
request: <RoleServer as ServiceRole>::PeerReq,
|
||||||
context: RequestContext<RoleServer>,
|
context: RequestContext<RoleServer>,
|
||||||
) -> Result<<RoleServer as ServiceRole>::Resp, McpError> {
|
) -> Result<<RoleServer as ServiceRole>::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
|
ClientRequest::InitializeRequest(request) => self
|
||||||
.initialize(request.params, context)
|
.initialize(request.params, context)
|
||||||
.await
|
.await
|
||||||
|
|
@ -127,7 +129,18 @@ impl<H: ServerHandler> Service<RoleServer> for H {
|
||||||
.cancel_task(request.params, context)
|
.cancel_task(request.params, context)
|
||||||
.await
|
.await
|
||||||
.map(ServerResult::CancelTaskResult),
|
.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(
|
async fn handle_notification(
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,7 @@ impl std::fmt::Display for ProtocolVersion {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl 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_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_06_18: Self = Self(Cow::Borrowed("2025-06-18"));
|
||||||
pub const V_2025_03_26: Self = Self(Cow::Borrowed("2025-03-26"));
|
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_03_26,
|
||||||
Self::V_2025_06_18,
|
Self::V_2025_06_18,
|
||||||
Self::V_2025_11_25,
|
Self::V_2025_11_25,
|
||||||
|
Self::V_2026_07_28,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Returns the string representation of this protocol version.
|
/// 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-03-26" => return Ok(ProtocolVersion::V_2025_03_26),
|
||||||
"2025-06-18" => return Ok(ProtocolVersion::V_2025_06_18),
|
"2025-06-18" => return Ok(ProtocolVersion::V_2025_06_18),
|
||||||
"2025-11-25" => return Ok(ProtocolVersion::V_2025_11_25),
|
"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)))
|
Ok(ProtocolVersion(Cow::Owned(s)))
|
||||||
|
|
@ -541,9 +544,12 @@ impl ErrorData {
|
||||||
data,
|
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<Cow<'static, str>>, data: Option<Value>) -> Self {
|
pub fn resource_not_found(message: impl Into<Cow<'static, str>>, data: Option<Value>) -> Self {
|
||||||
Self::new(ErrorCode::RESOURCE_NOT_FOUND, message, data)
|
Self::new(ErrorCode::RESOURCE_NOT_FOUND, message, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_error(message: impl Into<Cow<'static, str>>, data: Option<Value>) -> Self {
|
pub fn parse_error(message: impl Into<Cow<'static, str>>, data: Option<Value>) -> Self {
|
||||||
Self::new(ErrorCode::PARSE_ERROR, message, data)
|
Self::new(ErrorCode::PARSE_ERROR, message, data)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -672,6 +672,16 @@ impl<R: ServiceRole> RequestContext<R> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
impl RequestContext<RoleServer> {
|
||||||
|
/// The protocol version the client negotiated, or `None` before peer info is recorded.
|
||||||
|
pub fn protocol_version(&self) -> Option<crate::model::ProtocolVersion> {
|
||||||
|
self.peer
|
||||||
|
.peer_info()
|
||||||
|
.map(|info| info.protocol_version.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Request execution context
|
/// Request execution context
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
#[non_exhaustive]
|
#[non_exhaustive]
|
||||||
|
|
|
||||||
|
|
@ -866,16 +866,18 @@ async fn test_server_rejects_unsupported_protocol_version() {
|
||||||
fn test_protocol_version_utilities() {
|
fn test_protocol_version_utilities() {
|
||||||
use rmcp::model::ProtocolVersion;
|
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_11_25.as_str(), "2025-11-25");
|
||||||
assert_eq!(ProtocolVersion::V_2025_06_18.as_str(), "2025-06-18");
|
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_2025_03_26.as_str(), "2025-03-26");
|
||||||
assert_eq!(ProtocolVersion::V_2024_11_05.as_str(), "2024-11-05");
|
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_2024_11_05));
|
||||||
assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_03_26));
|
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_06_18));
|
||||||
assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_11_25));
|
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
|
/// Integration test: Verify server validates only the Host header for DNS rebinding protection
|
||||||
|
|
|
||||||
91
crates/rmcp/tests/test_resource_not_found_version.rs
Normal file
91
crates/rmcp/tests/test_resource_not_found_version.rs
Normal file
|
|
@ -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<RoleServer>,
|
||||||
|
) -> Result<ReadResourceResult, ErrorData> {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue