Skip to content

Commit 45b4940

Browse files
committed
feat: gate not-found code at server boundary
1 parent ebaaa46 commit 45b4940

5 files changed

Lines changed: 120 additions & 41 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().cloned();
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: 2 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -544,28 +544,12 @@ impl ErrorData {
544544
data,
545545
}
546546
}
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).
547549
pub fn resource_not_found(message: impl Into<Cow<'static, str>>, data: Option<Value>) -> Self {
548550
Self::new(ErrorCode::RESOURCE_NOT_FOUND, message, data)
549551
}
550552

551-
/// Create a resource-not-found error using the code required by the negotiated protocol version.
552-
///
553-
/// SEP-2164 standardizes resource-not-found as JSON-RPC `INVALID_PARAMS` (`-32602`)
554-
/// starting with protocol version `2026-07-28`. Older protocol versions continue to use
555-
/// the legacy MCP-specific `RESOURCE_NOT_FOUND` code (`-32002`).
556-
pub fn resource_not_found_for(
557-
protocol_version: &ProtocolVersion,
558-
message: impl Into<Cow<'static, str>>,
559-
data: Option<Value>,
560-
) -> Self {
561-
let code = if protocol_version.as_str() >= ProtocolVersion::V_2026_07_28.as_str() {
562-
ErrorCode::INVALID_PARAMS
563-
} else {
564-
ErrorCode::RESOURCE_NOT_FOUND
565-
};
566-
Self::new(code, message, data)
567-
}
568-
569553
pub fn parse_error(message: impl Into<Cow<'static, str>>, data: Option<Value>) -> Self {
570554
Self::new(ErrorCode::PARSE_ERROR, message, data)
571555
}
@@ -4043,26 +4027,6 @@ mod tests {
40434027
assert_eq!(json_url, expected_url_json);
40444028
}
40454029

4046-
#[test]
4047-
fn resource_not_found_for_uses_legacy_code_for_older_protocol_versions() {
4048-
let error = ErrorData::resource_not_found_for(
4049-
&ProtocolVersion::V_2025_11_25,
4050-
"resource not found",
4051-
None,
4052-
);
4053-
assert_eq!(error.code, ErrorCode::RESOURCE_NOT_FOUND);
4054-
}
4055-
4056-
#[test]
4057-
fn resource_not_found_for_uses_invalid_params_for_sep_2164_protocol_versions() {
4058-
let error = ErrorData::resource_not_found_for(
4059-
&ProtocolVersion::V_2026_07_28,
4060-
"resource not found",
4061-
None,
4062-
);
4063-
assert_eq!(error.code, ErrorCode::INVALID_PARAMS);
4064-
}
4065-
40664030
#[test]
40674031
fn notification_without_params_should_deserialize_as_bare_jsonrpc_message() {
40684032
let payload = b"{\"method\":\"notifications/initialized\",\"jsonrpc\":\"2.0\"}";

crates/rmcp/src/service.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,17 @@ impl<R: ServiceRole> RequestContext<R> {
674674
}
675675
}
676676

677+
#[cfg(feature = "server")]
678+
impl RequestContext<RoleServer> {
679+
/// The protocol version the client negotiated, or `None` before peer info is recorded.
680+
pub fn protocol_version(&self) -> Option<&crate::model::ProtocolVersion> {
681+
match self.peer.peer_info() {
682+
Some(info) => Some(&info.protocol_version),
683+
None => None,
684+
}
685+
}
686+
}
687+
677688
/// Request execution context
678689
#[derive(Debug, Clone)]
679690
#[non_exhaustive]
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)