Skip to content

Commit 95a8e96

Browse files
feat: standardize resource-not-found error code (SEP-2164) (modelcontextprotocol#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>
1 parent 8f5310b commit 95a8e96

6 files changed

Lines changed: 126 additions & 4 deletions

File tree

conformance/src/bin/server.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -615,7 +615,7 @@ impl ServerHandler for ConformanceServer {
615615
} else {
616616
Err(ErrorData::resource_not_found(
617617
format!("Resource not found: {}", uri),
618-
None,
618+
Some(json!({ "uri": uri })),
619619
))
620620
}
621621
}

crates/rmcp/src/handler/server.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ impl<H: ServerHandler> Service<RoleServer> for H {
2222
request: <RoleServer as ServiceRole>::PeerReq,
2323
context: RequestContext<RoleServer>,
2424
) -> Result<<RoleServer as ServiceRole>::Resp, McpError> {
25-
match request {
25+
// `context` is moved into the dispatch below, so read the negotiated version first.
26+
let protocol_version = context.protocol_version();
27+
let result = match request {
2628
ClientRequest::InitializeRequest(request) => self
2729
.initialize(request.params, context)
2830
.await
@@ -127,7 +129,18 @@ impl<H: ServerHandler> Service<RoleServer> for H {
127129
.cancel_task(request.params, context)
128130
.await
129131
.map(ServerResult::CancelTaskResult),
130-
}
132+
};
133+
// SEP-2164: peers negotiating 2026-07-28+ get the standard INVALID_PARAMS code for
134+
// resource-not-found; older peers keep RESOURCE_NOT_FOUND. ISO `YYYY-MM-DD` versions
135+
// compare lexically the same as chronologically.
136+
let use_invalid_params =
137+
protocol_version.is_some_and(|v| v.as_str() >= ProtocolVersion::V_2026_07_28.as_str());
138+
result.map_err(|mut error| {
139+
if use_invalid_params && error.code == ErrorCode::RESOURCE_NOT_FOUND {
140+
error.code = ErrorCode::INVALID_PARAMS;
141+
}
142+
error
143+
})
131144
}
132145

133146
async fn handle_notification(

crates/rmcp/src/model.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ impl std::fmt::Display for ProtocolVersion {
152152
}
153153

154154
impl ProtocolVersion {
155+
pub const V_2026_07_28: Self = Self(Cow::Borrowed("2026-07-28"));
155156
pub const V_2025_11_25: Self = Self(Cow::Borrowed("2025-11-25"));
156157
pub const V_2025_06_18: Self = Self(Cow::Borrowed("2025-06-18"));
157158
pub const V_2025_03_26: Self = Self(Cow::Borrowed("2025-03-26"));
@@ -164,6 +165,7 @@ impl ProtocolVersion {
164165
Self::V_2025_03_26,
165166
Self::V_2025_06_18,
166167
Self::V_2025_11_25,
168+
Self::V_2026_07_28,
167169
];
168170

169171
/// Returns the string representation of this protocol version.
@@ -193,6 +195,7 @@ impl<'de> Deserialize<'de> for ProtocolVersion {
193195
"2025-03-26" => return Ok(ProtocolVersion::V_2025_03_26),
194196
"2025-06-18" => return Ok(ProtocolVersion::V_2025_06_18),
195197
"2025-11-25" => return Ok(ProtocolVersion::V_2025_11_25),
198+
"2026-07-28" => return Ok(ProtocolVersion::V_2026_07_28),
196199
_ => {}
197200
}
198201
Ok(ProtocolVersion(Cow::Owned(s)))
@@ -541,9 +544,12 @@ impl ErrorData {
541544
data,
542545
}
543546
}
547+
/// Resource-not-found error (`-32002`). The server upgrades this to `INVALID_PARAMS`
548+
/// (`-32602`) for peers negotiating protocol `2026-07-28` or newer (SEP-2164).
544549
pub fn resource_not_found(message: impl Into<Cow<'static, str>>, data: Option<Value>) -> Self {
545550
Self::new(ErrorCode::RESOURCE_NOT_FOUND, message, data)
546551
}
552+
547553
pub fn parse_error(message: impl Into<Cow<'static, str>>, data: Option<Value>) -> Self {
548554
Self::new(ErrorCode::PARSE_ERROR, message, data)
549555
}

crates/rmcp/src/service.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,16 @@ impl<R: ServiceRole> RequestContext<R> {
672672
}
673673
}
674674

675+
#[cfg(feature = "server")]
676+
impl RequestContext<RoleServer> {
677+
/// The protocol version the client negotiated, or `None` before peer info is recorded.
678+
pub fn protocol_version(&self) -> Option<crate::model::ProtocolVersion> {
679+
self.peer
680+
.peer_info()
681+
.map(|info| info.protocol_version.clone())
682+
}
683+
}
684+
675685
/// Request execution context
676686
#[derive(Debug, Clone)]
677687
#[non_exhaustive]

crates/rmcp/tests/test_custom_headers.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -866,16 +866,18 @@ async fn test_server_rejects_unsupported_protocol_version() {
866866
fn test_protocol_version_utilities() {
867867
use rmcp::model::ProtocolVersion;
868868

869+
assert_eq!(ProtocolVersion::V_2026_07_28.as_str(), "2026-07-28");
869870
assert_eq!(ProtocolVersion::V_2025_11_25.as_str(), "2025-11-25");
870871
assert_eq!(ProtocolVersion::V_2025_06_18.as_str(), "2025-06-18");
871872
assert_eq!(ProtocolVersion::V_2025_03_26.as_str(), "2025-03-26");
872873
assert_eq!(ProtocolVersion::V_2024_11_05.as_str(), "2024-11-05");
873874

874-
assert_eq!(ProtocolVersion::KNOWN_VERSIONS.len(), 4);
875+
assert_eq!(ProtocolVersion::KNOWN_VERSIONS.len(), 5);
875876
assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2024_11_05));
876877
assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_03_26));
877878
assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_06_18));
878879
assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2025_11_25));
880+
assert!(ProtocolVersion::KNOWN_VERSIONS.contains(&ProtocolVersion::V_2026_07_28));
879881
}
880882

881883
/// Integration test: Verify server validates only the Host header for DNS rebinding protection
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
//! SEP-2164: the resource-not-found error code follows the negotiated protocol version.
2+
//!
3+
//! `2026-07-28` and newer get the standard `INVALID_PARAMS` (-32602); older versions
4+
//! keep the legacy `RESOURCE_NOT_FOUND` (-32002).
5+
#![cfg(not(feature = "local"))]
6+
#![cfg(feature = "client")]
7+
8+
use rmcp::{
9+
ClientHandler, RoleServer, ServerHandler, ServiceError, ServiceExt,
10+
model::{
11+
ClientInfo, ErrorCode, ErrorData, ProtocolVersion, ReadResourceRequestParams,
12+
ReadResourceResult,
13+
},
14+
service::RequestContext,
15+
};
16+
17+
#[derive(Debug, Clone, Default)]
18+
struct ResourceServer;
19+
20+
impl ServerHandler for ResourceServer {
21+
async fn read_resource(
22+
&self,
23+
_request: ReadResourceRequestParams,
24+
_context: RequestContext<RoleServer>,
25+
) -> Result<ReadResourceResult, ErrorData> {
26+
Err(ErrorData::resource_not_found("resource not found", None))
27+
}
28+
}
29+
30+
#[derive(Debug, Clone)]
31+
struct VersionedClient {
32+
protocol_version: ProtocolVersion,
33+
}
34+
35+
impl ClientHandler for VersionedClient {
36+
fn get_info(&self) -> ClientInfo {
37+
let mut info = ClientInfo::default();
38+
info.protocol_version = self.protocol_version.clone();
39+
info
40+
}
41+
}
42+
43+
async fn not_found_code(client_version: ProtocolVersion) -> ErrorCode {
44+
let (server_transport, client_transport) = tokio::io::duplex(4096);
45+
46+
let server_handle = tokio::spawn(async move {
47+
ResourceServer
48+
.serve(server_transport)
49+
.await?
50+
.waiting()
51+
.await?;
52+
anyhow::Ok(())
53+
});
54+
55+
let client = VersionedClient {
56+
protocol_version: client_version,
57+
}
58+
.serve(client_transport)
59+
.await
60+
.expect("client should connect");
61+
62+
let error = client
63+
.read_resource(ReadResourceRequestParams::new("missing://resource"))
64+
.await
65+
.expect_err("missing resource should error");
66+
67+
let code = match error {
68+
ServiceError::McpError(data) => data.code,
69+
other => panic!("expected McpError, got: {other:?}"),
70+
};
71+
72+
client.cancel().await.expect("client should cancel");
73+
server_handle.await.expect("server task").expect("server");
74+
code
75+
}
76+
77+
#[tokio::test]
78+
async fn legacy_version_gets_resource_not_found_code() {
79+
assert_eq!(
80+
not_found_code(ProtocolVersion::V_2025_11_25).await,
81+
ErrorCode::RESOURCE_NOT_FOUND,
82+
);
83+
}
84+
85+
#[tokio::test]
86+
async fn sep_2164_version_gets_invalid_params_code() {
87+
assert_eq!(
88+
not_found_code(ProtocolVersion::V_2026_07_28).await,
89+
ErrorCode::INVALID_PARAMS,
90+
);
91+
}

0 commit comments

Comments
 (0)